Compare commits

...

39 Commits

Author SHA1 Message Date
zarzet 67fc3e5de2 fix: revert AGP 9 to 8.13.2 - Flutter plugins not yet compatible with AGP 9 2026-02-07 20:46:23 +07:00
zarzet f1e6e9253f fix: opt out of AGP 9 newDsl for Flutter compatibility 2026-02-07 20:26:59 +07:00
zarzet 11c612e270 fix: remove kotlin-android plugin for AGP 9 built-in Kotlin support 2026-02-07 20:12:26 +07:00
zarzet cec5e49659 fix(deps): migrate flutter_local_notifications to v20 named params, update changelog with all dependency changes since 3.5.0 2026-02-07 20:02:11 +07:00
Zarz Eleutherius 1dbdb5f2c3 Update VirusTotal badge link in README 2026-02-07 19:57:44 +07:00
zarzet 086511d3e9 perf: unified parallel scheduler, dynamic concurrency 1-5, log truncation + FFmpeg command redaction 2026-02-07 19:57:44 +07:00
zarzet 3d366d21b7 perf: optimize providers, throttle polling, queued settings save, remove dead screens 2026-02-07 19:57:44 +07:00
zarzet 35f412dbd2 perf: replace PaletteService with blurred cover background, bump v3.5.1 2026-02-07 19:57:44 +07:00
Zarz Eleutherius c167aa0522 Merge pull request #136 from zarzet/renovate/major-go-dependencies
fix(deps): update go dependencies to v2 (major)
2026-02-07 19:56:07 +07:00
Zarz Eleutherius fccb3f3d78 Merge pull request #135 from zarzet/renovate/major-flutter-dependencies
fix(deps): update flutter dependencies (major)
2026-02-07 19:54:49 +07:00
Zarz Eleutherius 3a33283e94 Merge pull request #133 from zarzet/renovate/major-gradle-dependencies
chore(deps): update plugin com.android.application to v9
2026-02-07 19:49:33 +07:00
Zarz Eleutherius c74fb28a3a Merge pull request #131 from zarzet/renovate/actions-setup-java-5.x
chore(deps): update actions/setup-java action to v5
2026-02-07 19:49:18 +07:00
renovate[bot] ea504cc3ed fix(deps): update go dependencies to v2 2026-02-07 12:48:36 +00:00
renovate[bot] 61a2ad258e fix(deps): update flutter dependencies 2026-02-07 12:48:16 +00:00
Zarz Eleutherius ab62a8b1a9 Merge pull request #134 from zarzet/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2
2026-02-07 19:48:04 +07:00
Zarz Eleutherius 479eb1272d Merge pull request #132 from zarzet/renovate/major-github-artifact-actions
chore(deps): update github artifact actions (major)
2026-02-07 19:47:28 +07:00
renovate[bot] d23562e579 chore(deps): update softprops/action-gh-release action to v2 2026-02-07 12:47:07 +00:00
renovate[bot] 541d64bdd0 chore(deps): update plugin com.android.application to v9 2026-02-07 12:47:04 +00:00
renovate[bot] d4f7e6e494 chore(deps): update github artifact actions 2026-02-07 12:47:00 +00:00
renovate[bot] 532c08fe2e chore(deps): update actions/setup-java action to v5 2026-02-07 12:46:56 +00:00
Zarz Eleutherius 704b9674f4 Merge pull request #128 from zarzet/renovate/actions-cache-5.x
chore(deps): update actions/cache action to v5
2026-02-07 19:35:15 +07:00
Zarz Eleutherius 3de94280d2 Merge pull request #129 from zarzet/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2026-02-07 19:34:45 +07:00
Zarz Eleutherius 65897789f6 Merge pull request #130 from zarzet/renovate/actions-setup-go-6.x
chore(deps): update actions/setup-go action to v6
2026-02-07 19:34:29 +07:00
renovate[bot] 5d097c3a95 chore(deps): update actions/setup-go action to v6 2026-02-07 12:32:50 +00:00
renovate[bot] 4023e752a0 chore(deps): update actions/checkout action to v6 2026-02-07 12:32:47 +00:00
Zarz Eleutherius 9a722b1a24 Merge pull request #127 from zarzet/renovate/gradle-dependencies
fix(deps): update gradle dependencies
2026-02-07 19:31:18 +07:00
renovate[bot] 37b4727a29 chore(deps): update actions/cache action to v5 2026-02-07 11:49:57 +00:00
renovate[bot] 2604d0002a fix(deps): update gradle dependencies 2026-02-07 11:49:46 +00:00
Zarz Eleutherius cca337ab31 Merge pull request #125 from zarzet/renovate/go-dependencies
chore(deps): update dependency go to v1.25.7
2026-02-07 18:48:46 +07:00
renovate[bot] bb6e766a09 chore(deps): update dependency go to v1.25.7 2026-02-07 09:14:48 +00:00
zarzet 01cbdde70e Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-02-07 14:39:08 +07:00
Zarz Eleutherius e70ed311ed Merge pull request #123 from zarzet/renovate/com.android.tools-desugar_jdk_libs-2.x
chore(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5
2026-02-07 14:36:30 +07:00
Zarz Eleutherius c732cddf06 Merge pull request #122 from zarzet/renovate/golang.org-x-mobile-digest
chore(deps): update golang.org/x/mobile digest to 1dceadb
2026-02-07 14:36:16 +07:00
zarzet 1f71f957e2 chore: add Renovate config targeting dev branch with automerge 2026-02-07 14:35:37 +07:00
renovate[bot] 757c5fab19 chore(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 2026-02-07 07:32:37 +00:00
renovate[bot] cfa537db1f chore(deps): update golang.org/x/mobile digest to 1dceadb 2026-02-07 07:32:34 +00:00
Zarz Eleutherius 6e7c766945 Fix VirusTotal badge link formatting in README 2026-02-04 18:29:22 +07:00
Zarz Eleutherius 55b457a4c0 Update VirusTotal badge link in README
Updated VirusTotal badge link in README.md.
2026-02-04 18:28:50 +07:00
Zarz Eleutherius 423695c24d Update VirusTotal badge link in README.md 2026-02-01 22:00:38 +07:00
35 changed files with 1710 additions and 3124 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 2 # Need previous commit to compare fetch-depth: 2 # Need previous commit to compare
+16 -16
View File
@@ -60,23 +60,23 @@ jobs:
df -h df -h
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: "temurin" distribution: "temurin"
java-version: "17" java-version: "17"
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: "1.25" go-version: "1.25"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds # Cache Gradle for faster builds
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -158,7 +158,7 @@ jobs:
ls -la ls -la
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: android-apk name: android-apk
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
@@ -169,17 +169,17 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: "1.25" go-version: "1.25"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache CocoaPods # Cache CocoaPods
- name: Cache CocoaPods - name: Cache CocoaPods
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ios/Pods path: ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
@@ -295,7 +295,7 @@ jobs:
fi fi
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: ios-ipa name: ios-ipa
path: build/ios/ipa/SpotiFLAC-*.ipa path: build/ios/ipa/SpotiFLAC-*.ipa
@@ -308,7 +308,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Extract changelog for version - name: Extract changelog for version
id: changelog id: changelog
@@ -338,13 +338,13 @@ jobs:
cat /tmp/changelog.txt cat /tmp/changelog.txt
- name: Download Android APK - name: Download Android APK
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: android-apk name: android-apk
path: ./release path: ./release
- name: Download iOS IPA - name: Download iOS IPA
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: ios-ipa name: ios-ipa
path: ./release path: ./release
@@ -385,7 +385,7 @@ jobs:
cat /tmp/release_body.txt cat /tmp/release_body.txt
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ needs.get-version.outputs.version }} tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }} name: SpotiFLAC ${{ needs.get-version.outputs.version }}
@@ -403,16 +403,16 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Download Android APK - name: Download Android APK
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: android-apk name: android-apk
path: ./release path: ./release
- name: Download iOS IPA - name: Download iOS IPA
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: ios-ipa name: ios-ipa
path: ./release path: ./release
+73
View File
@@ -1,5 +1,78 @@
# Changelog # Changelog
## [3.5.1] - 2026-02-08
### Performance
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage
- Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction
- Removed `palette_generator` dependency
- App startup now renders immediately (`runApp`) while service initialization runs asynchronously in eager init
- Main shell provider subscriptions now use field-level `select(...)` to reduce unnecessary rebuilds
- Settings persistence now uses single-flight + queued save coalescing to avoid redundant disk writes
- Progress polling cadence adjusted to 800ms for download queue, local library scan progress, and Go log polling
- Android foreground download service progress updates are throttled (change-based updates + 5s heartbeat)
- SAF history repair is now batched (`20` items per batch) and capped per launch (`60`) to reduce startup I/O spikes
- Incremental library scan now builds final item list in-memory instead of reloading from database
- Local cover images in queue/library use direct `Image.file` with `errorBuilder` instead of `FutureBuilder` existence check
- CSV parser `_parseLine` rewritten: correct escaped-quote handling, no quote characters in output
- Removed unused legacy screen files (`home_screen.dart`, `queue_screen.dart`, `settings_screen.dart`, `settings_tab.dart`)
- Incremental local library scan now merges delta results in-memory and sorts once, avoiding full-state reload churn
- Queue local cover rendering now uses direct `Image.file` + `errorBuilder` (removed repeated async file-exists checks)
### Added
- Auto-cleanup orphaned downloads on history load (files that no longer exist are automatically removed from history)
### Changed
- Removed legacy screen files that were no longer used after the tab/part refactor:
- `lib/screens/home_screen.dart`
- `lib/screens/queue_screen.dart`
- `lib/screens/settings_screen.dart`
- `lib/screens/settings_tab.dart`
- Concurrent download limit increased from `3` to `5` (settings clamp + Options UI chips now support `1..5`)
- Download queue now uses a single parallel scheduler path; `1` concurrency is handled as parallel-with-limit-1 (no separate sequential engine)
- Download queue now listens to settings updates in real-time so concurrency/output settings stay in sync while queue is active
### Fixed
- CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import
- Fixed dynamic concurrency update during active downloads: changing limit (e.g. `1 -> 3`) now schedules additional queued items without waiting current active item to finish
- Queue scheduler now re-checks capacity/queued items on short intervals to avoid blocking on long-running single active download
### Dependencies
#### Flutter
- `flutter_local_notifications` 19.x → 20.0.0 (breaking: all positional params converted to named params)
- `connectivity_plus` 6.x → 7.0.0
- `flutter_secure_storage` 9.x → 10.0.0
- Removed `palette_generator` dependency
#### Go
- `go-flac/go-flac` v1.0.0 → v2.0.4
- `go-flac/flacvorbis` v0.2.0 → v2.0.2
- `go-flac/flacpicture` v0.3.0 → v2.0.2
- Go toolchain 1.24 → 1.25.7
#### Android
- Android Gradle Plugin 8.x → 9.0.0
- Kotlin 2.1.x → 2.3.10
- `desugar_jdk_libs` → 2.1.5
- `kotlinx-coroutines-android` → 1.10.2
- `lifecycle-runtime-ktx` → 2.10.0
- `activity-ktx` → 1.12.3
#### CI/CD
- `actions/cache` v4 → v5
- `actions/checkout` v4 → v6
- `actions/setup-go` v5 → v6
- `actions/setup-java` v4 → v5
- `softprops/action-gh-release` v1 → v2
- GitHub artifact actions updated
---
## [3.5.0] - 2026-02-07 ## [3.5.0] - 2026-02-07
### Highlights ### Highlights
+1 -1
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/f6ba8fa4a572d69f6196f980733089cb741088e3ceb49d0bd3ceda5a694a2466/)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center"> <div align="center">
+5 -5
View File
@@ -96,13 +96,13 @@ repositories {
} }
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// Include all AAR and JAR files from libs folder // Include all AAR and JAR files from libs folder
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar")))) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.documentfile:documentfile:1.0.1") implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.9.0") implementation("androidx.activity:activity-ktx:1.12.3")
} }
+1 -1
View File
@@ -22,7 +22,7 @@ subprojects {
} }
// Add desugaring dependency to all Android subprojects // Add desugaring dependency to all Android subprojects
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4") project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
} }
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.3.0" apply false id("org.jetbrains.kotlin.android") version "2.2.21" apply false
} }
include(":app") include(":app")
+5 -2
View File
@@ -2,15 +2,18 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0 go 1.25.0
toolchain go1.25.6 toolchain go1.25.7
require ( require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac v1.0.0 github.com/go-flac/go-flac v1.0.0
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2 github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
golang.org/x/net v0.49.0 golang.org/x/net v0.49.0
) )
+5
View File
@@ -8,10 +8,13 @@ github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI= github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -26,6 +29,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU= golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I= golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+17 -2
View File
@@ -22,6 +22,11 @@ type LogBuffer struct {
loggingEnabled bool loggingEnabled bool
} }
const (
defaultLogBufferSize = 500
maxLogMessageLength = 500
)
var ( var (
globalLogBuffer *LogBuffer globalLogBuffer *LogBuffer
logBufferOnce sync.Once logBufferOnce sync.Once
@@ -30,14 +35,22 @@ var (
func GetLogBuffer() *LogBuffer { func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() { logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{ globalLogBuffer = &LogBuffer{
entries: make([]LogEntry, 0, 1000), entries: make([]LogEntry, 0, defaultLogBufferSize),
maxSize: 1000, maxSize: defaultLogBufferSize,
loggingEnabled: false, // Default: disabled for performance (user can enable in settings) loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
} }
}) })
return globalLogBuffer return globalLogBuffer
} }
func truncateLogMessage(message string) string {
runes := []rune(message)
if len(runes) <= maxLogMessageLength {
return message
}
return string(runes[:maxLogMessageLength]) + "...[truncated]"
}
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
@@ -58,6 +71,8 @@ func (lb *LogBuffer) Add(level, tag, message string) {
return return
} }
message = truncateLogMessage(message)
entry := LogEntry{ entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"), Timestamp: time.Now().Format("15:04:05.000"),
Level: level, Level: level,
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.5.0'; static const String version = '3.5.1';
static const String buildNumber = '74'; static const String buildNumber = '75';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+22 -18
View File
@@ -11,21 +11,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await CoverCacheManager.initialize();
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
runApp( runApp(
ProviderScope( ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
child: const _EagerInitialization(
child: SpotiFLACApp(),
),
),
); );
} }
@@ -35,27 +23,43 @@ class _EagerInitialization extends ConsumerStatefulWidget {
final Widget child; final Widget child;
@override @override
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState(); ConsumerState<_EagerInitialization> createState() =>
_EagerInitializationState();
} }
class _EagerInitializationState extends ConsumerState<_EagerInitialization> { class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initializeAppServices();
_initializeExtensions(); _initializeExtensions();
ref.read(downloadHistoryProvider); ref.read(downloadHistoryProvider);
} }
Future<void> _initializeAppServices() async {
try {
await CoverCacheManager.initialize();
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
} catch (e) {
debugPrint('Failed to initialize app services: $e');
}
}
Future<void> _initializeExtensions() async { Future<void> _initializeExtensions() async {
try { try {
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions'; final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data'; final dataDir = '${appDir.path}/extension_data';
await Directory(extensionsDir).create(recursive: true); await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true); await Directory(dataDir).create(recursive: true);
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); await ref
.read(extensionProvider.notifier)
.initialize(extensionsDir, dataDir);
} catch (e) { } catch (e) {
debugPrint('Failed to initialize extensions: $e'); debugPrint('Failed to initialize extensions: $e');
} }
+207 -107
View File
@@ -226,8 +226,11 @@ class DownloadHistoryState {
} }
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> { class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
final HistoryDatabase _db = HistoryDatabase.instance; final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false; bool _isLoaded = false;
bool _isSafRepairInProgress = false;
@override @override
DownloadHistoryState build() { DownloadHistoryState build() {
@@ -267,8 +270,14 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (Platform.isAndroid) { if (Platform.isAndroid) {
Future.microtask(() async { Future.microtask(() async {
await _repairMissingSafEntries(items); await _repairMissingSafEntries(
items,
maxItems: _safRepairMaxPerLaunch,
);
await cleanupOrphanedDownloads();
}); });
} else {
Future.microtask(() => cleanupOrphanedDownloads());
} }
} catch (e, stack) { } catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack); _historyLog.e('Failed to load history from database: $e', e, stack);
@@ -285,10 +294,16 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return ''; return '';
} }
Future<void> _repairMissingSafEntries(List<DownloadHistoryItem> items) async { Future<void> _repairMissingSafEntries(
final updatedItems = [...items]; List<DownloadHistoryItem> items, {
var changed = false; required int maxItems,
}) async {
if (_isSafRepairInProgress || items.isEmpty) {
return;
}
_isSafRepairInProgress = true;
final candidateIndexes = <int>[];
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
final item = items[i]; final item = items[i];
if (item.storageMode != 'saf') continue; if (item.storageMode != 'saf') continue;
@@ -299,46 +314,85 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (item.filePath.isEmpty || !isContentUri(item.filePath)) { if (item.filePath.isEmpty || !isContentUri(item.filePath)) {
continue; continue;
} }
candidateIndexes.add(i);
final exists = await fileExists(item.filePath); if (candidateIndexes.length >= maxItems) break;
if (exists) continue;
final fallbackName = item.safFileName ?? _fileNameFromUri(item.filePath);
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
continue;
}
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = resolved['uri'] as String? ?? '';
if (newUri.isEmpty) continue;
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir: (newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
updatedItems[i] = updated;
changed = true;
await _db.upsert(updated.toJson());
_historyLog.i('Repaired SAF URI for history item: ${item.id}');
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
} }
if (changed) { if (candidateIndexes.isEmpty) {
state = state.copyWith(items: updatedItems); _isSafRepairInProgress = false;
return;
}
final updatedItems = [...items];
var changed = false;
var repairedCount = 0;
var verifiedCount = 0;
try {
for (var c = 0; c < candidateIndexes.length; c++) {
final i = candidateIndexes[c];
final item = items[i];
final exists = await fileExists(item.filePath);
if (exists) {
final verified = item.copyWith(
safRepaired: true,
safFileName: item.safFileName ?? _fileNameFromUri(item.filePath),
);
updatedItems[i] = verified;
changed = true;
verifiedCount++;
await _db.upsert(verified.toJson());
} else {
final fallbackName =
item.safFileName ?? _fileNameFromUri(item.filePath);
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
continue;
}
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = resolved['uri'] as String? ?? '';
if (newUri.isEmpty) continue;
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir:
(newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
updatedItems[i] = updated;
changed = true;
repairedCount++;
await _db.upsert(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
}
if ((c + 1) % _safRepairBatchSize == 0) {
await Future.delayed(const Duration(milliseconds: 16));
}
}
if (changed) {
state = state.copyWith(items: updatedItems);
_historyLog.i(
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${candidateIndexes.length}',
);
}
} finally {
_isSafRepairInProgress = false;
} }
} }
@@ -412,18 +466,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
/// Returns the number of orphaned entries removed /// Returns the number of orphaned entries removed
Future<int> cleanupOrphanedDownloads() async { Future<int> cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...'); _historyLog.i('Starting orphaned downloads cleanup...');
final entries = await _db.getAllEntriesWithPaths(); final entries = await _db.getAllEntriesWithPaths();
final orphanedIds = <String>[]; final orphanedIds = <String>[];
for (final entry in entries) { for (final entry in entries) {
final id = entry['id'] as String; final id = entry['id'] as String;
final filePath = entry['file_path'] as String?; final filePath = entry['file_path'] as String?;
if (filePath == null || filePath.isEmpty) continue; if (filePath == null || filePath.isEmpty) continue;
bool exists = false; bool exists = false;
if (filePath.startsWith('content://')) { if (filePath.startsWith('content://')) {
// SAF path - check via platform bridge // SAF path - check via platform bridge
try { try {
@@ -436,31 +490,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
// Regular file path // Regular file path
exists = File(filePath).existsSync(); exists = File(filePath).existsSync();
} }
if (!exists) { if (!exists) {
orphanedIds.add(id); orphanedIds.add(id);
_historyLog.d('Found orphaned entry: $id ($filePath)'); _historyLog.d('Found orphaned entry: $id ($filePath)');
} }
} }
if (orphanedIds.isEmpty) { if (orphanedIds.isEmpty) {
_historyLog.i('No orphaned entries found'); _historyLog.i('No orphaned entries found');
return 0; return 0;
} }
// Delete from database // Delete from database
final deletedCount = await _db.deleteByIds(orphanedIds); final deletedCount = await _db.deleteByIds(orphanedIds);
// Update in-memory state // Update in-memory state
final orphanedSet = orphanedIds.toSet(); final orphanedSet = orphanedIds.toSet();
state = state.copyWith( state = state.copyWith(
items: state.items.where((item) => !orphanedSet.contains(item.id)).toList(), items: state.items
.where((item) => !orphanedSet.contains(item.id))
.toList(),
); );
_historyLog.i('Cleaned up $deletedCount orphaned entries'); _historyLog.i('Cleaned up $deletedCount orphaned entries');
return deletedCount; return deletedCount;
} }
void clearHistory() { void clearHistory() {
state = DownloadHistoryState(); state = DownloadHistoryState();
_db.clearAll().catchError((e) { _db.clearAll().catchError((e) {
@@ -557,6 +613,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _downloadCount = 0; int _downloadCount = 0;
static const _cleanupInterval = 50; static const _cleanupInterval = 50;
static const _queueStorageKey = 'download_queue'; static const _queueStorageKey = 'download_queue';
static const _progressPollingInterval = Duration(milliseconds: 800);
static const _queueSchedulingInterval = Duration(milliseconds: 250);
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance(); final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
int _totalQueuedAtStart = 0; int _totalQueuedAtStart = 0;
@@ -564,15 +622,33 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _failedInSession = 0; int _failedInSession = 0;
bool _isLoaded = false; bool _isLoaded = false;
final Set<String> _ensuredDirs = {}; final Set<String> _ensuredDirs = {};
int _progressPollingErrorCount = 0;
String? _lastServiceTrackName;
String? _lastServiceArtistName;
int _lastServicePercent = -1;
int _lastServiceQueueCount = -1;
DateTime _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
@override @override
DownloadQueueState build() { DownloadQueueState build() {
ref.listen<AppSettings>(settingsProvider, (previous, next) {
final previousConcurrent =
previous?.concurrentDownloads ?? state.concurrentDownloads;
updateSettings(next);
if (previousConcurrent != next.concurrentDownloads) {
_log.i(
'Concurrent downloads updated: $previousConcurrent -> ${next.concurrentDownloads}',
);
}
});
ref.onDispose(() { ref.onDispose(() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
}); });
Future.microtask(() async { Future.microtask(() async {
updateSettings(ref.read(settingsProvider));
await _initOutputDir(); await _initOutputDir();
await _loadQueueFromStorage(); await _loadQueueFromStorage();
}); });
@@ -647,9 +723,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void _startMultiProgressPolling() { void _startMultiProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), ( _progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
timer,
) async {
try { try {
final allProgress = await PlatformBridge.getAllDownloadProgress(); final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {}; final items = allProgress['items'] as Map<String, dynamic>? ?? {};
@@ -818,23 +892,76 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
if (Platform.isAndroid) { if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress( _maybeUpdateAndroidDownloadService(
trackName: firstDownloading.track.name, trackName: firstDownloading.track.name,
artistName: firstDownloading.track.artistName, artistName: firstDownloading.track.artistName,
progress: notifProgress, progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1, total: notifTotal > 0 ? notifTotal : 1,
queueCount: queuedCount, queueCount: queuedCount,
).catchError((_) {}); );
} }
} }
} }
} catch (_) {} _progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Progress polling failed: $e');
}
}
}); });
} }
void _maybeUpdateAndroidDownloadService({
required String trackName,
required String artistName,
required int progress,
required int total,
required int queueCount,
}) {
final now = DateTime.now();
final safeTotal = total > 0 ? total : 1;
final progressPercent = ((progress * 100) / safeTotal)
.round()
.clamp(0, 100)
.toInt();
final didContentChange =
trackName != _lastServiceTrackName ||
artistName != _lastServiceArtistName ||
queueCount != _lastServiceQueueCount ||
progressPercent != _lastServicePercent;
final allowHeartbeat =
now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5);
if (!didContentChange && !allowHeartbeat) {
return;
}
_lastServiceTrackName = trackName;
_lastServiceArtistName = artistName;
_lastServicePercent = progressPercent;
_lastServiceQueueCount = queueCount;
_lastServiceUpdateAt = now;
PlatformBridge.updateDownloadServiceProgress(
trackName: trackName,
artistName: artistName,
progress: progress,
total: safeTotal,
queueCount: queueCount,
).catchError((_) {});
}
void _stopProgressPolling() { void _stopProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
_progressPollingErrorCount = 0;
_lastServiceTrackName = null;
_lastServiceArtistName = null;
_lastServicePercent = -1;
_lastServiceQueueCount = -1;
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
} }
Future<void> _initOutputDir() async { Future<void> _initOutputDir() async {
@@ -1108,6 +1235,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
void updateSettings(AppSettings settings) { void updateSettings(AppSettings settings) {
final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5);
state = state.copyWith( state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty outputDir: settings.downloadDirectory.isNotEmpty
? settings.downloadDirectory ? settings.downloadDirectory
@@ -1115,7 +1243,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
filenameFormat: settings.filenameFormat, filenameFormat: settings.filenameFormat,
audioQuality: settings.audioQuality, audioQuality: settings.audioQuality,
autoFallback: settings.autoFallback, autoFallback: settings.autoFallback,
concurrentDownloads: settings.concurrentDownloads, concurrentDownloads: concurrentDownloads,
); );
} }
@@ -2064,6 +2192,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Check network connectivity before starting // Check network connectivity before starting
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
updateSettings(settings);
final isSafMode = _isSafMode(settings); final isSafMode = _isSafMode(settings);
if (settings.downloadNetworkMode == 'wifi_only') { if (settings.downloadNetworkMode == 'wifi_only') {
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
@@ -2174,12 +2303,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
_log.d('Concurrent downloads: ${state.concurrentDownloads}'); _log.d('Concurrent downloads: ${state.concurrentDownloads}');
await _processQueueParallel();
if (state.concurrentDownloads > 1) {
await _processQueueParallel();
} else {
await _processQueueSequential();
}
_stopProgressPolling(); _stopProgressPolling();
@@ -2235,56 +2359,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
Future<void> _processQueueSequential() async {
_startMultiProgressPolling();
while (true) {
if (state.isPaused) {
_log.d('Queue is paused, waiting...');
await Future.delayed(const Duration(milliseconds: 500));
continue;
}
final currentItems = state.items;
final nextIndex = currentItems.indexWhere(
(item) => item.status == DownloadStatus.queued,
);
if (nextIndex == -1) {
_log.d(
'No more items to process (checked ${currentItems.length} items)',
);
break;
}
final nextItem = currentItems[nextIndex];
_log.d(
'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})',
);
await _downloadSingleItem(nextItem);
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
}
_stopProgressPolling();
}
Future<void> _processQueueParallel() async { Future<void> _processQueueParallel() async {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{}; final activeDownloads = <String, Future<void>>{};
var lastLoggedMaxConcurrent = -1;
_startMultiProgressPolling(); _startMultiProgressPolling();
while (true) { while (true) {
if (state.isPaused) { if (state.isPaused) {
_log.d('Queue is paused, waiting for active downloads...'); _log.d('Queue is paused, waiting for active downloads...');
if (activeDownloads.isNotEmpty) { await Future.delayed(_queueSchedulingInterval);
await Future.any(activeDownloads.values);
} else {
await Future.delayed(const Duration(milliseconds: 500));
}
continue; continue;
} }
final maxConcurrent = max(1, state.concurrentDownloads);
if (lastLoggedMaxConcurrent != maxConcurrent) {
_log.d('Parallel worker max concurrency now: $maxConcurrent');
lastLoggedMaxConcurrent = maxConcurrent;
}
final queuedItems = state.items final queuedItems = state.items
.where((item) => item.status == DownloadStatus.queued) .where((item) => item.status == DownloadStatus.queued)
.toList(); .toList();
@@ -2313,7 +2406,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
if (activeDownloads.isNotEmpty) { if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values); // Re-check queue/settings periodically so concurrency changes
// (e.g. 1 -> 3) can take effect before any active item finishes.
await Future.any([
Future.any(activeDownloads.values),
Future.delayed(_queueSchedulingInterval),
]);
} else {
await Future.delayed(_queueSchedulingInterval);
} }
} }
+121 -65
View File
@@ -36,16 +36,16 @@ class LocalLibraryState {
this.scanErrorCount = 0, this.scanErrorCount = 0,
this.scanWasCancelled = false, this.scanWasCancelled = false,
this.lastScannedAt, this.lastScannedAt,
}) : _isrcSet = items }) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty) .where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!) .map((item) => item.isrc!)
.toSet(), .toSet(),
_trackKeySet = items.map((item) => item.matchKey).toSet(), _trackKeySet = items.map((item) => item.matchKey).toSet(),
_byIsrc = Map.fromEntries( _byIsrc = Map.fromEntries(
items items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty) .where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)), .map((item) => MapEntry(item.isrc!, item)),
); );
bool hasIsrc(String isrc) => _isrcSet.contains(isrc); bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
@@ -99,9 +99,11 @@ class LocalLibraryState {
class LocalLibraryNotifier extends Notifier<LocalLibraryState> { class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance; final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance;
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer; Timer? _progressTimer;
bool _isLoaded = false; bool _isLoaded = false;
bool _scanCancelRequested = false; bool _scanCancelRequested = false;
int _progressPollingErrorCount = 0;
@override @override
LocalLibraryState build() { LocalLibraryState build() {
@@ -121,10 +123,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final jsonList = await _db.getAll(); final jsonList = await _db.getAll();
final items = jsonList final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
.map((e) => LocalLibraryItem.fromJson(e))
.toList();
DateTime? lastScannedAt; DateTime? lastScannedAt;
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -135,9 +135,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} catch (e) { } catch (e) {
_log.w('Failed to load lastScannedAt: $e'); _log.w('Failed to load lastScannedAt: $e');
} }
state = state.copyWith(items: items, lastScannedAt: lastScannedAt); state = state.copyWith(items: items, lastScannedAt: lastScannedAt);
_log.i('Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt'); _log.i(
'Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt',
);
} catch (e, stack) { } catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack); _log.e('Failed to load library from database: $e', e, stack);
} }
@@ -148,14 +150,19 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase(); await _loadFromDatabase();
} }
Future<void> startScan(String folderPath, {bool forceFullScan = false}) async { Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
}) async {
if (state.isScanning) { if (state.isScanning) {
_log.w('Scan already in progress'); _log.w('Scan already in progress');
return; return;
} }
_scanCancelRequested = false; _scanCancelRequested = false;
_log.i('Starting library scan: $folderPath (incremental: ${!forceFullScan})'); _log.i(
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
);
state = state.copyWith( state = state.copyWith(
isScanning: true, isScanning: true,
scanProgress: 0, scanProgress: 0,
@@ -179,11 +186,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final isSaf = folderPath.startsWith('content://'); final isSaf = folderPath.startsWith('content://');
// Get all file paths from download history to exclude them // Get all file paths from download history to exclude them
final downloadedPaths = await _historyDb.getAllFilePaths(); final downloadedPaths = await _historyDb.getAllFilePaths();
_log.i('Excluding ${downloadedPaths.length} downloaded files from library scan'); _log.i(
'Excluding ${downloadedPaths.length} downloaded files from library scan',
);
if (forceFullScan) { if (forceFullScan) {
// Full scan path - ignores existing data // Full scan path - ignores existing data
final results = isSaf final results = isSaf
@@ -193,7 +202,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(isScanning: false, scanWasCancelled: true);
return; return;
} }
final items = <LocalLibraryItem>[]; final items = <LocalLibraryItem>[];
int skippedDownloads = 0; int skippedDownloads = 0;
for (final json in results) { for (final json in results) {
@@ -206,7 +215,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final item = LocalLibraryItem.fromJson(json); final item = LocalLibraryItem.fromJson(json);
items.add(item); items.add(item);
} }
if (skippedDownloads > 0) { if (skippedDownloads > 0) {
_log.i('Skipped $skippedDownloads files already in download history'); _log.i('Skipped $skippedDownloads files already in download history');
} }
@@ -234,7 +243,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} else { } else {
// Incremental scan path - only scans new/modified files // Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes(); final existingFiles = await _db.getFileModTimes();
_log.i('Incremental scan: ${existingFiles.length} existing files in database'); _log.i(
'Incremental scan: ${existingFiles.length} existing files in database',
);
final backfilledModTimes = await _backfillLegacyFileModTimes( final backfilledModTimes = await _backfillLegacyFileModTimes(
isSaf: isSaf, isSaf: isSaf,
@@ -245,7 +256,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
existingFiles.addAll(backfilledModTimes); existingFiles.addAll(backfilledModTimes);
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times'); _log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
} }
// Use appropriate incremental scan method based on SAF or not // Use appropriate incremental scan method based on SAF or not
final Map<String, dynamic> result; final Map<String, dynamic> result;
if (isSaf) { if (isSaf) {
@@ -259,63 +270,76 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
existingFiles, existingFiles,
); );
} }
if (_scanCancelRequested) { if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(isScanning: false, scanWasCancelled: true);
return; return;
} }
// Parse incremental scan result // Parse incremental scan result
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths' // SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList = (result['files'] as List<dynamic>?) final scannedList =
?? (result['scanned'] as List<dynamic>?) (result['files'] as List<dynamic>?) ??
?? []; (result['scanned'] as List<dynamic>?) ??
final deletedPaths = (result['removedUris'] as List<dynamic>?) [];
?.map((e) => e as String) final deletedPaths =
.toList() (result['removedUris'] as List<dynamic>?)
?? (result['deletedPaths'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
.toList() .toList() ??
?? []; (result['deletedPaths'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[];
final skippedCount = result['skippedCount'] as int? ?? 0; final skippedCount = result['skippedCount'] as int? ?? 0;
final totalFiles = result['totalFiles'] as int? ?? 0; final totalFiles = result['totalFiles'] as int? ?? 0;
_log.i('Incremental result: ${scannedList.length} scanned, ' _log.i(
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total'); 'Incremental result: ${scannedList.length} scanned, '
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
);
final currentByPath = <String, LocalLibraryItem>{
for (final item in state.items) item.filePath: item,
};
// Upsert new/modified items (excluding downloaded files) // Upsert new/modified items (excluding downloaded files)
final updatedItems = <LocalLibraryItem>[];
int skippedDownloads = 0;
if (scannedList.isNotEmpty) { if (scannedList.isNotEmpty) {
final items = <LocalLibraryItem>[];
int skippedDownloads = 0;
for (final json in scannedList) { for (final json in scannedList) {
final map = json as Map<String, dynamic>; final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?; final filePath = map['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) { if (filePath != null && downloadedPaths.contains(filePath)) {
skippedDownloads++; skippedDownloads++;
continue; continue;
} }
items.add(LocalLibraryItem.fromJson(map)); final item = LocalLibraryItem.fromJson(map);
updatedItems.add(item);
currentByPath[item.filePath] = item;
} }
if (items.isNotEmpty) { if (updatedItems.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList()); await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList());
_log.i('Upserted ${items.length} items'); _log.i('Upserted ${updatedItems.length} items');
} }
if (skippedDownloads > 0) { if (skippedDownloads > 0) {
_log.i('Skipped $skippedDownloads files already in download history'); _log.i(
'Skipped $skippedDownloads files already in download history',
);
} }
} }
// Delete removed items // Delete removed items
if (deletedPaths.isNotEmpty) { if (deletedPaths.isNotEmpty) {
final deleteCount = await _db.deleteByPaths(deletedPaths); final deleteCount = await _db.deleteByPaths(deletedPaths);
for (final path in deletedPaths) {
currentByPath.remove(path);
}
_log.i('Deleted $deleteCount items from database'); _log.i('Deleted $deleteCount items from database');
} }
// Reload all items from database to get complete list final items = currentByPath.values.toList(growable: false)
final allItems = await _db.getAll(); ..sort(_compareLibraryItems);
final items = allItems.map((e) => LocalLibraryItem.fromJson(e)).toList();
final now = DateTime.now(); final now = DateTime.now();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -333,8 +357,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanWasCancelled: false, scanWasCancelled: false,
); );
_log.i('Incremental scan complete: ${items.length} total tracks ' _log.i(
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)'); 'Incremental scan complete: ${items.length} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)',
);
} }
} catch (e, stack) { } catch (e, stack) {
_log.e('Library scan failed: $e', e, stack); _log.e('Library scan failed: $e', e, stack);
@@ -346,10 +372,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
void _startProgressPolling() { void _startProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { _progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
try { try {
final progress = await PlatformBridge.getLibraryScanProgress(); final progress = await PlatformBridge.getLibraryScanProgress();
state = state.copyWith( state = state.copyWith(
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0, scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
scanCurrentFile: progress['current_file'] as String?, scanCurrentFile: progress['current_file'] as String?,
@@ -361,18 +387,25 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (progress['is_complete'] == true) { if (progress['is_complete'] == true) {
_stopProgressPolling(); _stopProgressPolling();
} }
} catch (_) {} _progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Library scan progress polling failed: $e');
}
}
}); });
} }
void _stopProgressPolling() { void _stopProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
_progressPollingErrorCount = 0;
} }
Future<void> cancelScan() async { Future<void> cancelScan() async {
if (!state.isScanning) return; if (!state.isScanning) return;
_log.i('Cancelling library scan'); _log.i('Cancelling library scan');
_scanCancelRequested = true; _scanCancelRequested = true;
await PlatformBridge.cancelLibraryScan(); await PlatformBridge.cancelLibraryScan();
@@ -390,14 +423,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<void> clearLibrary() async { Future<void> clearLibrary() async {
await _db.clearAll(); await _db.clearAll();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey); await prefs.remove(_lastScannedAtKey);
} catch (e) { } catch (e) {
_log.w('Failed to clear lastScannedAt: $e'); _log.w('Failed to clear lastScannedAt: $e');
} }
state = LocalLibraryState(); state = LocalLibraryState();
_log.i('Library cleared'); _log.i('Library cleared');
} }
@@ -421,7 +454,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return state.getByIsrc(isrc); return state.getByIsrc(isrc);
} }
LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) { LocalLibraryItem? findExisting({
String? isrc,
String? trackName,
String? artistName,
}) {
if (isrc != null && isrc.isNotEmpty) { if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc); final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc; if (byIsrc != null) return byIsrc;
@@ -434,7 +471,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<List<LocalLibraryItem>> search(String query) async { Future<List<LocalLibraryItem>> search(String query) async {
if (query.isEmpty) return []; if (query.isEmpty) return [];
final results = await _db.search(query); final results = await _db.search(query);
return results.map((e) => LocalLibraryItem.fromJson(e)).toList(); return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
} }
@@ -443,6 +480,23 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return await _db.getCount(); return await _db.getCount();
} }
int _compareLibraryItems(LocalLibraryItem a, LocalLibraryItem b) {
final artistA = (a.albumArtist ?? a.artistName).toLowerCase();
final artistB = (b.albumArtist ?? b.artistName).toLowerCase();
final artistCompare = artistA.compareTo(artistB);
if (artistCompare != 0) return artistCompare;
final albumCompare = a.albumName.toLowerCase().compareTo(
b.albumName.toLowerCase(),
);
if (albumCompare != 0) return albumCompare;
final discCompare = (a.discNumber ?? 0).compareTo(b.discNumber ?? 0);
if (discCompare != 0) return discCompare;
return (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0);
}
Future<Map<String, int>> _backfillLegacyFileModTimes({ Future<Map<String, int>> _backfillLegacyFileModTimes({
required bool isSaf, required bool isSaf,
required Map<String, int> existingFiles, required Map<String, int> existingFiles,
@@ -469,7 +523,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_scanCancelRequested) { if (_scanCancelRequested) {
break; break;
} }
final end = (i + chunkSize < uris.length) ? i + chunkSize : uris.length; final end = (i + chunkSize < uris.length)
? i + chunkSize
: uris.length;
final chunk = uris.sublist(i, end); final chunk = uris.sublist(i, end);
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk); final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
backfilled.addAll(chunkResult); backfilled.addAll(chunkResult);
+46 -19
View File
@@ -10,10 +10,14 @@ const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version'; const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 2; const _currentMigrationVersion = 2;
const _spotifyClientSecretKey = 'spotify_client_secret'; const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> { class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance(); final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
bool _isSavingSettings = false;
bool _saveQueued = false;
String? _pendingSettingsJson;
@override @override
AppSettings build() { AppSettings build() {
@@ -26,27 +30,27 @@ class SettingsNotifier extends Notifier<AppSettings> {
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson(jsonDecode(json)); state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs); await _runMigrations(prefs);
} }
await _loadSpotifyClientSecret(prefs); await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials(); _applySpotifyCredentials();
LogBuffer.loggingEnabled = state.enableLogging; LogBuffer.loggingEnabled = state.enableLogging;
} }
Future<void> _runMigrations(SharedPreferences prefs) async { Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) { if (lastMigration < 1) {
if (!state.useCustomSpotifyCredentials) { if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer'); state = state.copyWith(metadataSource: 'deezer');
await _saveSettings(); await _saveSettings();
} }
} }
if (lastMigration < _currentMigrationVersion) { if (lastMigration < _currentMigrationVersion) {
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') { if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
state = state.copyWith(storageMode: 'saf'); state = state.copyWith(storageMode: 'saf');
@@ -61,20 +65,43 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
final prefs = await _prefs; final settingsToSave = state.copyWith(spotifyClientSecret: '');
final settingsToSave = state.copyWith( _pendingSettingsJson = jsonEncode(settingsToSave.toJson());
spotifyClientSecret: '',
); if (_isSavingSettings) {
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson())); _saveQueued = true;
return;
}
_isSavingSettings = true;
try {
final prefs = await _prefs;
do {
final jsonToWrite = _pendingSettingsJson;
_saveQueued = false;
if (jsonToWrite != null) {
await prefs.setString(_settingsKey, jsonToWrite);
}
} while (_saveQueued);
} catch (e) {
_log.e('Failed to save settings: $e');
} finally {
_isSavingSettings = false;
}
} }
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async { Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey); final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
final prefsSecret = state.spotifyClientSecret; final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) && if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) { prefsSecret.isNotEmpty) {
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret); await _secureStorage.write(
key: _spotifyClientSecretKey,
value: prefsSecret,
);
} }
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty) final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
@@ -99,7 +126,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _applySpotifyCredentials() async { Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty && if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) { state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials( await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId, state.spotifyClientId,
@@ -172,7 +199,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
void setConcurrentDownloads(int count) { void setConcurrentDownloads(int count) {
final clamped = count.clamp(1, 3); final clamped = count.clamp(1, 5);
state = state.copyWith(concurrentDownloads: clamped); state = state.copyWith(concurrentDownloads: clamped);
_saveSettings(); _saveSettings();
} }
@@ -225,7 +252,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async { Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
state = state.copyWith( state = state.copyWith(
spotifyClientId: clientId, spotifyClientId: clientId,
spotifyClientSecret: clientSecret, spotifyClientSecret: clientSecret,
@@ -236,10 +266,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> clearSpotifyCredentials() async { Future<void> clearSpotifyCredentials() async {
state = state.copyWith( state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
spotifyClientId: '',
spotifyClientSecret: '',
);
await _storeSpotifyClientSecret(''); await _storeSpotifyClientSecret('');
_saveSettings(); _saveSettings();
_applySpotifyCredentials(); _applySpotifyCredentials();
@@ -301,7 +328,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setUseAllFilesAccess(bool enabled) { void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled); state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings(); _saveSettings();
} }
+27 -25
View File
@@ -1,7 +1,7 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
@@ -69,7 +69,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks; List<Track>? _tracks;
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
String? _artistId; String? _artistId;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -103,8 +102,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (_tracks == null || _tracks!.isEmpty) { if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks(); _fetchTracks();
} }
_extractDominantColor();
} }
@override @override
@@ -121,14 +118,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
} }
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
String _formatReleaseDate(String date) { String _formatReleaseDate(String date) {
if (date.length >= 10) { if (date.length >= 10) {
final parts = date.substring(0, 10).split('-'); final parts = date.substring(0, 10).split('-');
@@ -232,7 +221,6 @@ Future<void> _fetchTracks() async {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: 320,
@@ -264,18 +252,32 @@ Future<void> _fetchTracks() async {
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
AnimatedContainer( // Blurred cover background
duration: const Duration(milliseconds: 500), if (widget.coverUrl != null)
decoration: BoxDecoration( CachedNetworkImage(
gradient: LinearGradient( imageUrl: widget.coverUrl!,
begin: Alignment.topCenter, fit: BoxFit.cover,
end: Alignment.bottomCenter, cacheManager: CoverCacheManager.instance,
colors: [ placeholder: (_, _) => Container(color: colorScheme.surface),
bgColor, errorWidget: (_, _, _) => Container(color: colorScheme.surface),
bgColor.withValues(alpha: 0.8), )
colorScheme.surface, else
], Container(color: colorScheme.surface),
stops: const [0.0, 0.6, 1.0], ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
),
), ),
), ),
), ),
+27 -40
View File
@@ -1,9 +1,9 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -29,7 +29,6 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> { class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bool _isSelectionMode = false; bool _isSelectionMode = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -37,7 +36,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_extractDominantColor();
} }
@override @override
@@ -54,29 +52,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
} }
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
// Check cache first (instant)
final cached = PaletteService.instance.getCached(widget.coverUrl);
if (cached != null) {
if (mounted && cached != _dominantColor) {
setState(() {
_dominantColor = cached;
});
}
return;
}
// Extract in isolate (non-blocking)
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
/// Get tracks for this album from history provider (reactive) /// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) { List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) { return allItems.where((item) {
@@ -294,7 +269,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: 320,
@@ -326,19 +300,32 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Background with dominant color // Blurred cover background
AnimatedContainer( if (widget.coverUrl != null)
duration: const Duration(milliseconds: 500), CachedNetworkImage(
decoration: BoxDecoration( imageUrl: widget.coverUrl!,
gradient: LinearGradient( fit: BoxFit.cover,
begin: Alignment.topCenter, cacheManager: CoverCacheManager.instance,
end: Alignment.bottomCenter, placeholder: (_, _) => Container(color: colorScheme.surface),
colors: [ errorWidget: (_, _, _) => Container(color: colorScheme.surface),
bgColor, )
bgColor.withValues(alpha: 0.8), else
colorScheme.surface, Container(color: colorScheme.surface),
], ClipRect(
stops: const [0.0, 0.6, 1.0], child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
),
), ),
), ),
), ),
-413
View File
@@ -1,413 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
final _urlController = TextEditingController();
int _currentIndex = 0;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null) {
_urlController.text = data!.text!;
}
}
Future<void> _fetchMetadata() async {
final url = _urlController.text.trim();
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
} else {
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
}
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(
track,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
}
void _downloadAll() {
final trackState = ref.read(trackProvider);
if (trackState.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
trackState.tracks,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')),
);
}
void _onNavTap(int index) {
setState(() => _currentIndex = index);
switch (index) {
case 0:
break;
case 1:
context.push('/queue');
break;
case 2:
context.push('/history');
break;
}
}
@override
Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider);
final queuedCount =
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold(
appBar: AppBar(
leading: Padding(
padding: const EdgeInsets.all(8.0),
child: CircleAvatar(
backgroundColor: colorScheme.primaryContainer,
child: Icon(Icons.music_note, color: colorScheme.onPrimaryContainer, size: 20),
),
),
title: const Text('SpotiFLAC'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _urlController,
decoration: InputDecoration(
hintText: 'Paste Spotify URL or search...',
prefixIcon: const Icon(Icons.link),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
],
),
),
onSubmitted: (_) => _fetchMetadata(),
),
),
if (trackState.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
trackState.error!,
style: TextStyle(color: colorScheme.error),
),
),
if (trackState.isLoading)
LinearProgressIndicator(color: colorScheme.primary),
if (trackState.albumName != null || trackState.playlistName != null)
_buildHeader(trackState, colorScheme),
if (tracks.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: FilledButton.icon(
onPressed: _downloadAll,
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
),
Expanded(
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _onNavTap,
destinations: [
const NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Badge(
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music_outlined),
),
selectedIcon: Badge(
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music),
),
label: 'Queue',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'History',
),
],
),
);
}
Widget _buildHeader(TrackState state, ColorScheme colorScheme) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (state.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: state.coverUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(
width: 80,
height: 80,
color: colorScheme.surfaceContainerHighest,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.albumName ?? state.playlistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${state.tracks.length} tracks',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
),
child: const Icon(Icons.download),
),
],
),
),
);
}
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
final isCollection = track.isCollection;
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
final capitalizedType = typeLabel.isNotEmpty
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
: 'Album';
final year = track.releaseDate != null && track.releaseDate!.length >= 4
? track.releaseDate!.substring(0, 4)
: '';
subtitleText = '$capitalizedType${track.artistName}${year.isNotEmpty ? '$year' : ''}';
} else {
subtitleText = track.artistName;
}
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isCollection ? Icons.album : Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: isCollection
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
);
}
Future<void> _openCollection(Track track) async {
final extensionId = track.source;
if (extensionId == null) return;
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
if (albumData != null && mounted) {
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
albumName: albumData['name'] as String? ?? track.name,
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
);
}
} else if (track.isPlaylistItem) {
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
if (playlistData != null && mounted) {
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
playlistName: playlistData['name'] as String? ?? track.name,
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load: $e')),
);
}
}
}
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? '').toString(),
albumName: (data['album_name'] ?? '').toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
duration: (durationMs / 1000).round(),
releaseDate: data['release_date']?.toString(),
source: source,
);
}
String _formatDuration(int ms) {
if (ms == 0) return '';
final duration = Duration(milliseconds: ms);
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
Widget _buildEmptyState(ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Paste a Spotify URL to get started',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
+25 -42
View File
@@ -1,11 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
/// Screen to display tracks from a local library album /// Screen to display tracks from a local library album
@@ -30,7 +30,6 @@ class LocalAlbumScreen extends ConsumerStatefulWidget {
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> { class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bool _isSelectionMode = false; bool _isSelectionMode = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache; late List<LocalLibraryItem> _sortedTracksCache;
@@ -43,13 +42,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_rebuildTrackCaches(); _rebuildTrackCaches();
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null) {
_dominantColor = cachedColor;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_extractDominantColor();
});
} }
@override @override
@@ -59,13 +51,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
oldWidget.tracks.length != widget.tracks.length) { oldWidget.tracks.length != widget.tracks.length) {
_rebuildTrackCaches(); _rebuildTrackCaches();
} }
if (oldWidget.coverPath != widget.coverPath) {
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null && cachedColor != _dominantColor) {
_dominantColor = cachedColor;
}
_extractDominantColor();
}
} }
@override @override
@@ -82,18 +67,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} }
} }
Future<void> _extractDominantColor() async {
if (widget.coverPath == null || widget.coverPath!.isEmpty) return;
// Extract color from local file
final color = await PaletteService.instance.extractDominantColorFromFile(widget.coverPath!);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
List<LocalLibraryItem> _buildSortedTracks() { List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks); final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) { tracks.sort((a, b) {
@@ -289,7 +262,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: 320,
@@ -321,19 +293,30 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Background with dominant color // Blurred cover background
AnimatedContainer( if (widget.coverPath != null)
duration: const Duration(milliseconds: 500), Image.file(
decoration: BoxDecoration( File(widget.coverPath!),
gradient: LinearGradient( fit: BoxFit.cover,
begin: Alignment.topCenter, errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
end: Alignment.bottomCenter, )
colors: [ else
bgColor, Container(color: colorScheme.surface),
bgColor.withValues(alpha: 0.8), ClipRect(
colorScheme.surface, child: BackdropFilter(
], filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
stops: const [0.0, 0.6, 1.0], child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
),
), ),
), ),
), ),
+24 -11
View File
@@ -82,9 +82,9 @@ class _MainShellState extends ConsumerState<MainShell> {
} }
} }
} }
if (!mounted) return; if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
if (_currentIndex != 0) { if (_currentIndex != 0) {
@@ -181,10 +181,14 @@ class _MainShellState extends ConsumerState<MainShell> {
final treeUri = result['tree_uri'] as String? ?? ''; final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? ''; final displayName = result['display_name'] as String? ?? '';
if (treeUri.isNotEmpty) { if (treeUri.isNotEmpty) {
ref.read(settingsProvider.notifier).setDownloadTreeUri( ref
treeUri, .read(settingsProvider.notifier)
displayName: displayName.isNotEmpty ? displayName : treeUri, .setDownloadTreeUri(
); treeUri,
displayName: displayName.isNotEmpty
? displayName
: treeUri,
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -280,7 +284,16 @@ class _MainShellState extends ConsumerState<MainShell> {
final queueState = ref.watch( final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount), downloadQueueProvider.select((s) => s.queuedCount),
); );
final trackState = ref.watch(trackProvider); final trackHasSearchText = ref.watch(
trackProvider.select((s) => s.hasSearchText),
);
final trackHasContent = ref.watch(
trackProvider.select((s) => s.hasContent),
);
final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final trackIsShowingRecentAccess = ref.watch(
trackProvider.select((s) => s.isShowingRecentAccess),
);
final showStore = ref.watch( final showStore = ref.watch(
settingsProvider.select((s) => s.showExtensionStore), settingsProvider.select((s) => s.showExtensionStore),
); );
@@ -292,10 +305,10 @@ class _MainShellState extends ConsumerState<MainShell> {
final canPop = final canPop =
_currentIndex == 0 && _currentIndex == 0 &&
!trackState.hasSearchText && !trackHasSearchText &&
!trackState.hasContent && !trackHasContent &&
!trackState.isLoading && !trackIsLoading &&
!trackState.isShowingRecentAccess && !trackIsShowingRecentAccess &&
!isKeyboardVisible; !isKeyboardVisible;
final tabs = <Widget>[ final tabs = <Widget>[
+27 -25
View File
@@ -1,7 +1,7 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
@@ -32,7 +32,6 @@ class PlaylistScreen extends ConsumerStatefulWidget {
} }
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> { class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
List<Track>? _fetchedTracks; List<Track>? _fetchedTracks;
@@ -45,7 +44,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_extractDominantColor();
_fetchTracksIfNeeded(); _fetchTracksIfNeeded();
} }
@@ -122,14 +120,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} }
} }
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -151,7 +141,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: 320,
@@ -183,19 +172,32 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Background with dominant color // Blurred cover background
AnimatedContainer( if (widget.coverUrl != null)
duration: const Duration(milliseconds: 500), CachedNetworkImage(
decoration: BoxDecoration( imageUrl: widget.coverUrl!,
gradient: LinearGradient( fit: BoxFit.cover,
begin: Alignment.topCenter, cacheManager: CoverCacheManager.instance,
end: Alignment.bottomCenter, placeholder: (_, _) => Container(color: colorScheme.surface),
colors: [ errorWidget: (_, _, _) => Container(color: colorScheme.surface),
bgColor, )
bgColor.withValues(alpha: 0.8), else
colorScheme.surface, Container(color: colorScheme.surface),
], ClipRect(
stops: const [0.0, 0.6, 1.0], child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
),
), ),
), ),
), ),
-248
View File
@@ -1,248 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
class QueueScreen extends ConsumerWidget {
const QueueScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.queueTitle),
actions: [
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: context.l10n.queueClearCompleted,
),
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
tooltip: context.l10n.queueClearAll,
),
],
),
body: items.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) =>
_buildQueueItem(context, ref, items[index], colorScheme),
),
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
context.l10n.queueEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
context.l10n.queueEmptySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
return ListTile(
leading: item.track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
),
const SizedBox(width: 8),
Text(
'${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
],
),
],
],
),
trailing: _buildStatusIcon(context, item, colorScheme),
onTap: item.status == DownloadStatus.queued
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
: null,
);
}
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
switch (item.status) {
case DownloadStatus.queued:
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
case DownloadStatus.downloading:
return SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: item.progress,
strokeWidth: 2,
color: colorScheme.primary,
),
);
case DownloadStatus.finalizing:
return SizedBox(
width: 24,
height: 24,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
],
),
);
case DownloadStatus.completed:
return Icon(Icons.check_circle, color: colorScheme.primary);
case DownloadStatus.failed:
return IconButton(
icon: Icon(Icons.error, color: colorScheme.error),
onPressed: () => _showErrorDialog(context, item, colorScheme),
tooltip: 'Tap to see error details',
);
case DownloadStatus.skipped:
return Icon(Icons.skip_next, color: colorScheme.primary);
}
}
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
Text(context.l10n.queueDownloadFailed),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
const SizedBox(height: 16),
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.error ?? context.l10n.queueUnknownError,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogClose),
),
],
),
);
}
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
}
+595 -613
View File
File diff suppressed because it is too large Load Diff
+96 -60
View File
@@ -24,46 +24,48 @@ class OptionsSettingsPage extends ConsumerWidget {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 120 + topPadding, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxHeight = 120 + topPadding; final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding; final minHeight = kToolbarHeight + topPadding;
final expandRatio = final expandRatio =
((constraints.maxHeight - minHeight) / ((constraints.maxHeight - minHeight) /
(maxHeight - minHeight)) (maxHeight - minHeight))
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar( return FlexibleSpaceBar(
expandedTitleScale: 1.0, expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only( titlePadding: EdgeInsets.only(
left: leftPadding, left: leftPadding,
bottom: 16, bottom: 16,
),
title: Text(
context.l10n.optionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
), ),
), title: Text(
); context.l10n.optionsTitle,
}, style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource), child: SettingsSectionHeader(
title: context.l10n.sectionSearchSource,
),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
@@ -86,14 +88,18 @@ class OptionsSettingsPage extends ConsumerWidget {
children: [ children: [
Icon( Icon(
Icons.warning_amber_rounded, Icons.warning_amber_rounded,
color: Theme.of(context).colorScheme.onErrorContainer, color: Theme.of(
context,
).colorScheme.onErrorContainer,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
context.l10n.optionsSpotifyWarning, context.l10n.optionsSpotifyWarning,
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer, color: Theme.of(
context,
).colorScheme.onErrorContainer,
fontSize: 12, fontSize: 12,
), ),
), ),
@@ -107,7 +113,11 @@ class OptionsSettingsPage extends ConsumerWidget {
icon: Icons.key, icon: Icons.key,
title: context.l10n.optionsSpotifyCredentials, title: context.l10n.optionsSpotifyCredentials,
subtitle: settings.spotifyClientId.isNotEmpty subtitle: settings.spotifyClientId.isNotEmpty
? context.l10n.optionsSpotifyCredentialsConfigured(settings.spotifyClientId.length > 8 ? settings.spotifyClientId.substring(0, 8) : settings.spotifyClientId) ? context.l10n.optionsSpotifyCredentialsConfigured(
settings.spotifyClientId.length > 8
? settings.spotifyClientId.substring(0, 8)
: settings.spotifyClientId,
)
: context.l10n.optionsSpotifyCredentialsRequired, : context.l10n.optionsSpotifyCredentialsRequired,
onTap: () => onTap: () =>
_showSpotifyCredentialsDialog(context, ref, settings), _showSpotifyCredentialsDialog(context, ref, settings),
@@ -168,7 +178,9 @@ class OptionsSettingsPage extends ConsumerWidget {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionPerformance), child: SettingsSectionHeader(
title: context.l10n.sectionPerformance,
),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
@@ -277,9 +289,7 @@ class OptionsSettingsPage extends ConsumerWidget {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(context.l10n.dialogClearHistoryTitle), title: Text(context.l10n.dialogClearHistoryTitle),
content: Text( content: Text(context.l10n.dialogClearHistoryMessage),
context.l10n.dialogClearHistoryMessage,
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -289,11 +299,14 @@ class OptionsSettingsPage extends ConsumerWidget {
onPressed: () { onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory(); ref.read(downloadHistoryProvider.notifier).clearHistory();
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.snackbarHistoryCleared)),
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarHistoryCleared))); );
}, },
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)), child: Text(
context.l10n.dialogClear,
style: TextStyle(color: colorScheme.error),
),
), ),
], ],
), ),
@@ -323,7 +336,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final removed = await ref final removed = await ref
.read(downloadHistoryProvider.notifier) .read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads(); .cleanupOrphanedDownloads();
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); // Close loading dialog Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -339,9 +352,9 @@ class OptionsSettingsPage extends ConsumerWidget {
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); // Close loading dialog Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Error: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Error: $e')));
} }
} }
} }
@@ -493,7 +506,11 @@ class OptionsSettingsPage extends ConsumerWidget {
.setSpotifyCredentials(clientId, clientSecret); .setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)), SnackBar(
content: Text(
context.l10n.snackbarCredentialsSaved,
),
),
); );
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -524,7 +541,11 @@ class OptionsSettingsPage extends ConsumerWidget {
.clearSpotifyCredentials(); .clearSpotifyCredentials();
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)), SnackBar(
content: Text(
context.l10n.snackbarCredentialsCleared,
),
),
); );
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
@@ -582,7 +603,9 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
Text( Text(
currentValue == 1 currentValue == 1
? context.l10n.optionsConcurrentSequential ? context.l10n.optionsConcurrentSequential
: context.l10n.optionsConcurrentParallel(currentValue), : context.l10n.optionsConcurrentParallel(
currentValue,
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -612,6 +635,18 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
isSelected: currentValue == 3, isSelected: currentValue == 3,
onTap: () => onChanged(3), onTap: () => onChanged(3),
), ),
const SizedBox(width: 8),
_ConcurrentChip(
label: '4',
isSelected: currentValue == 4,
onTap: () => onChanged(4),
),
const SizedBox(width: 8),
_ConcurrentChip(
label: '5',
isSelected: currentValue == 5,
onTap: () => onChanged(5),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -837,20 +872,21 @@ class _MetadataSourceSelector extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
Extension? activeExtension; Extension? activeExtension;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { if (settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty) {
activeExtension = extState.extensions activeExtension = extState.extensions
.where((e) => e.id == settings.searchProvider && e.enabled) .where((e) => e.id == settings.searchProvider && e.enabled)
.firstOrNull; .firstOrNull;
} }
final hasExtensionSearch = activeExtension != null; final hasExtensionSearch = activeExtension != null;
String? extensionName; String? extensionName;
if (hasExtensionSearch) { if (hasExtensionSearch) {
extensionName = activeExtension.displayName; extensionName = activeExtension.displayName;
} }
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -868,8 +904,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
? context.l10n.optionsUsingExtension(extensionName!) ? context.l10n.optionsUsingExtension(extensionName!)
: context.l10n.optionsPrimaryProviderSubtitle, : context.l10n.optionsPrimaryProviderSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: hasExtensionSearch color: hasExtensionSearch
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
), ),
-540
View File
@@ -1,540 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
// Theme Section
_buildSectionHeader(context, 'Appearance', colorScheme),
// Theme Mode
ListTile(
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
title: const Text('Theme Mode'),
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
),
// Dynamic Color Toggle
SwitchListTile(
secondary: Icon(Icons.palette, color: colorScheme.primary),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
),
// Seed Color Picker (only when dynamic color is disabled)
if (!themeSettings.useDynamicColor)
ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Color(themeSettings.seedColorValue),
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outline),
),
),
title: const Text('Accent Color'),
subtitle: const Text('Choose your preferred color'),
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
const Divider(),
// Download Section
_buildSectionHeader(context, 'Download', colorScheme),
// Download Service
ListTile(
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
title: const Text('Default Service'),
subtitle: Text(_getServiceName(settings.defaultService)),
onTap: () => _showServicePicker(context, ref, settings.defaultService),
),
// Audio Quality
ListTile(
leading: Icon(Icons.high_quality, color: colorScheme.primary),
title: const Text('Audio Quality'),
subtitle: Text(_getQualityName(settings.audioQuality)),
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
),
// Filename Format
ListTile(
leading: Icon(Icons.text_fields, color: colorScheme.primary),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
// Download Directory
ListTile(
leading: Icon(Icons.folder, color: colorScheme.primary),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
onTap: () => _pickDirectory(context, ref),
),
const Divider(),
// Options Section
_buildSectionHeader(context, 'Options', colorScheme),
// Auto Fallback
SwitchListTile(
secondary: Icon(Icons.sync, color: colorScheme.primary),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
),
// Embed Lyrics
SwitchListTile(
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
),
// Max Quality Cover
SwitchListTile(
secondary: Icon(Icons.image, color: colorScheme.primary),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
// About
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
case ThemeMode.dark: return 'Dark';
case ThemeMode.system: return 'System';
}
}
String _getServiceName(String service) {
switch (service) {
case 'tidal': return 'Tidal';
case 'qobuz': return 'Qobuz';
case 'amazon': return 'Amazon Music';
default: return service;
}
}
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (Lossless)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
default: return quality;
}
}
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
],
),
),
);
}
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
final isSelected = mode == current;
return ListTile(
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(themeProvider.notifier).setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
final colors = [
const Color(0xFF1DB954), // Spotify Green
const Color(0xFF6750A4), // Purple
const Color(0xFF0061A4), // Blue
const Color(0xFF006E1C), // Green
const Color(0xFFBA1A1A), // Red
const Color(0xFF984061), // Pink
const Color(0xFF7D5260), // Brown
const Color(0xFF006874), // Teal
const Color(0xFFFF6F00), // Orange
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choose Accent Color'),
content: Wrap(
spacing: 12,
runSpacing: 12,
children: colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () {
ref.read(themeProvider.notifier).setSeedColor(color);
Navigator.pop(context);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3)
: null,
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
),
);
}
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
],
),
),
);
}
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDefaultService(value);
Navigator.pop(context);
},
);
}
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
);
}
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAudioQuality(value);
Navigator.pop(context);
},
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filename Format'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
hintText: '{artist} - {title}',
),
),
const SizedBox(height: 16),
Text(
'Available placeholders:',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'{title}, {artist}, {album}, {track}, {year}, {disc}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
-520
View File
@@ -1,520 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class SettingsTab extends ConsumerStatefulWidget {
const SettingsTab({super.key});
@override
ConsumerState<SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
final settings = ref.watch(settingsProvider);
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return ListView(
children: [
// Theme Section
_buildSectionHeader(context, 'Appearance', colorScheme),
// Theme Mode
ListTile(
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
title: const Text('Theme Mode'),
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
),
// Dynamic Color Toggle
SwitchListTile(
secondary: Icon(Icons.palette, color: colorScheme.primary),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
),
// Seed Color Picker (only when dynamic color is disabled)
if (!themeSettings.useDynamicColor)
ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Color(themeSettings.seedColorValue),
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outline),
),
),
title: const Text('Accent Color'),
subtitle: const Text('Choose your preferred color'),
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
const Divider(),
// Download Section
_buildSectionHeader(context, 'Download', colorScheme),
// Download Service
ListTile(
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
title: const Text('Default Service'),
subtitle: Text(_getServiceName(settings.defaultService)),
onTap: () => _showServicePicker(context, ref, settings.defaultService),
),
// Audio Quality
ListTile(
leading: Icon(Icons.high_quality, color: colorScheme.primary),
title: const Text('Audio Quality'),
subtitle: Text(_getQualityName(settings.audioQuality)),
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
),
// Filename Format
ListTile(
leading: Icon(Icons.text_fields, color: colorScheme.primary),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
// Download Directory
ListTile(
leading: Icon(Icons.folder, color: colorScheme.primary),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
onTap: () => _pickDirectory(context, ref),
),
const Divider(),
// Options Section
_buildSectionHeader(context, 'Options', colorScheme),
// Auto Fallback
SwitchListTile(
secondary: Icon(Icons.sync, color: colorScheme.primary),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
),
// Embed Lyrics
SwitchListTile(
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
),
// Max Quality Cover
SwitchListTile(
secondary: Icon(Icons.image, color: colorScheme.primary),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
// About
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
// Bottom padding for navigation bar
const SizedBox(height: 16),
],
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
case ThemeMode.dark: return 'Dark';
case ThemeMode.system: return 'System';
}
}
String _getServiceName(String service) {
switch (service) {
case 'tidal': return 'Tidal';
case 'qobuz': return 'Qobuz';
case 'amazon': return 'Amazon Music';
default: return service;
}
}
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
default: return quality;
}
}
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
],
),
),
);
}
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
final isSelected = mode == current;
return ListTile(
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(themeProvider.notifier).setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
final colors = [
const Color(0xFF1DB954), const Color(0xFF6750A4), const Color(0xFF0061A4),
const Color(0xFF006E1C), const Color(0xFFBA1A1A), const Color(0xFF984061),
const Color(0xFF7D5260), const Color(0xFF006874), const Color(0xFFFF6F00),
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choose Accent Color'),
content: Wrap(
spacing: 12,
runSpacing: 12,
children: colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () {
ref.read(themeProvider.notifier).setSeedColor(color);
Navigator.pop(context);
},
child: Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle,
border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null,
),
child: isSelected ? const Icon(Icons.check, color: Colors.white) : null,
),
);
}).toList(),
),
),
);
}
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
],
),
),
);
}
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDefaultService(value);
Navigator.pop(context);
},
);
}
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
);
}
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAudioQuality(value);
Navigator.pop(context);
},
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filename Format'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}')),
const SizedBox(height: 16),
Text('Available placeholders:', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 4),
Text('{title}, {artist}, {album}, {track}, {year}, {disc}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(
onPressed: () {
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
+48 -50
View File
@@ -1,11 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -31,7 +31,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? _rawLyrics; // Raw LRC with timestamps for embedding String? _rawLyrics; // Raw LRC with timestamps for embedding
bool _lyricsLoading = false; bool _lyricsLoading = false;
String? _lyricsError; String? _lyricsError;
Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
bool _isEmbedding = false; // Track embed operation in progress bool _isEmbedding = false; // Track embed operation in progress
@@ -69,10 +68,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_checkFile(); _checkFile();
// Delay palette extraction to avoid jitter during initial build
WidgetsBinding.instance.addPostFrameCallback((_) {
_extractDominantColor();
});
} }
@override @override
@@ -89,35 +84,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
Future<void> _extractDominantColor() async {
// For local items with cover path, extract from file
if (_isLocalItem && _localCoverPath != null && _localCoverPath!.isNotEmpty) {
final color = await PaletteService.instance.extractDominantColorFromFile(_localCoverPath!);
if (mounted && color != null && color != _dominantColor) {
setState(() => _dominantColor = color);
}
return;
}
final coverUrl = _coverUrl;
if (coverUrl == null) return;
// Check cache first
final cachedColor = PaletteService.instance.getCached(coverUrl);
if (cachedColor != null) {
if (mounted && cachedColor != _dominantColor) {
setState(() => _dominantColor = cachedColor);
}
return;
}
// Extract using PaletteService (runs in isolate)
final color = await PaletteService.instance.extractDominantColor(coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() => _dominantColor = color);
}
}
Future<void> _checkFile() async { Future<void> _checkFile() async {
var filePath = _filePath; var filePath = _filePath;
if (filePath.startsWith('EXISTS:')) { if (filePath.startsWith('EXISTS:')) {
@@ -184,7 +150,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
@@ -217,7 +182,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return FlexibleSpaceBar( return FlexibleSpaceBar(
collapseMode: CollapseMode.none, collapseMode: CollapseMode.none,
background: _buildHeaderBackground(context, colorScheme, coverSize, bgColor, showContent), background: _buildHeaderBackground(context, colorScheme, coverSize, showContent),
stretchModes: const [ stretchModes: const [
StretchMode.zoomBackground, StretchMode.zoomBackground,
StretchMode.blurBackground, StretchMode.blurBackground,
@@ -285,26 +250,59 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
); );
} }
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, Color bgColor, bool showContent) { Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, bool showContent) {
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
AnimatedContainer( // Blurred cover art background
duration: const Duration(milliseconds: 500), if (_coverUrl != null)
decoration: BoxDecoration( CachedNetworkImage(
gradient: LinearGradient( imageUrl: _coverUrl!,
begin: Alignment.topCenter, fit: BoxFit.cover,
end: Alignment.bottomCenter, cacheManager: CoverCacheManager.instance,
colors: [ placeholder: (_, _) => Container(color: colorScheme.surface),
bgColor, errorWidget: (_, _, _) => Container(color: colorScheme.surface),
bgColor.withValues(alpha: 0.8), )
colorScheme.surface, else if (_localCoverPath != null && _localCoverPath!.isNotEmpty)
], Image.file(
stops: const [0.0, 0.6, 1.0], File(_localCoverPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
// Blur filter
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
), ),
), ),
), ),
// Bottom fade to surface
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
),
),
),
),
// Cover art
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
+114 -83
View File
@@ -21,7 +21,7 @@ class CsvImportService {
final file = File(result.files.single.path!); final file = File(result.files.single.path!);
final content = await file.readAsString(); final content = await file.readAsString();
final tracks = _parseCsv(content); final tracks = _parseCsv(content);
if (tracks.isNotEmpty) { if (tracks.isNotEmpty) {
return await _enrichTracksMetadata(tracks, onProgress: onProgress); return await _enrichTracksMetadata(tracks, onProgress: onProgress);
} }
@@ -39,43 +39,50 @@ class CsvImportService {
}) async { }) async {
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...'); _log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
final enrichedTracks = <Track>[]; final enrichedTracks = <Track>[];
for (int i = 0; i < tracks.length; i++) { for (int i = 0; i < tracks.length; i++) {
final track = tracks[i]; final track = tracks[i];
onProgress?.call(i + 1, tracks.length); onProgress?.call(i + 1, tracks.length);
if (track.coverUrl == null || track.duration == 0) { if (track.coverUrl == null || track.duration == 0) {
Map<String, dynamic>? trackData; Map<String, dynamic>? trackData;
if (track.isrc != null && track.isrc!.isNotEmpty) { if (track.isrc != null && track.isrc!.isNotEmpty) {
try { try {
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!); trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
_log.d('ISRC enrichment success for ${track.name}'); _log.d('ISRC enrichment success for ${track.name}');
} catch (e) { } catch (e) {
_log.w('ISRC search failed for ${track.name}, trying text search...'); _log.w(
'ISRC search failed for ${track.name}, trying text search...',
);
} }
} }
if (trackData == null) { if (trackData == null) {
try { try {
final query = '${track.artistName} ${track.name}'; final query = '${track.artistName} ${track.name}';
final searchResult = await PlatformBridge.searchDeezerAll(query, trackLimit: 5); final searchResult = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 5,
);
if (searchResult.containsKey('tracks')) { if (searchResult.containsKey('tracks')) {
final tracksList = searchResult['tracks'] as List<dynamic>?; final tracksList = searchResult['tracks'] as List<dynamic>?;
if (tracksList != null && tracksList.isNotEmpty) { if (tracksList != null && tracksList.isNotEmpty) {
for (final result in tracksList) { for (final result in tracksList) {
final resultMap = result as Map<String, dynamic>; final resultMap = result as Map<String, dynamic>;
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? ''; final resultName =
(resultMap['name'] as String?)?.toLowerCase() ?? '';
final trackNameLower = track.name.toLowerCase(); final trackNameLower = track.name.toLowerCase();
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) { if (resultName.contains(trackNameLower) ||
trackNameLower.contains(resultName)) {
trackData = resultMap; trackData = resultMap;
_log.d('Text search match for ${track.name}: $resultName'); _log.d('Text search match for ${track.name}: $resultName');
break; break;
} }
} }
if (trackData == null && tracksList.isNotEmpty) { if (trackData == null && tracksList.isNotEmpty) {
trackData = tracksList.first as Map<String, dynamic>; trackData = tracksList.first as Map<String, dynamic>;
_log.d('Using first search result for ${track.name}'); _log.d('Using first search result for ${track.name}');
@@ -86,38 +93,44 @@ class CsvImportService {
_log.w('Text search also failed for ${track.name}: $e'); _log.w('Text search also failed for ${track.name}: $e');
} }
} }
if (trackData != null) { if (trackData != null) {
final coverUrl = trackData['images'] as String?; final coverUrl = trackData['images'] as String?;
final durationMs = trackData['duration_ms'] as int? ?? 0; final durationMs = trackData['duration_ms'] as int? ?? 0;
final deezerIdRaw = trackData['spotify_id'] as String?; final deezerIdRaw = trackData['spotify_id'] as String?;
enrichedTracks.add(Track( enrichedTracks.add(
id: deezerIdRaw ?? track.id, Track(
name: trackData['name'] as String? ?? track.name, id: deezerIdRaw ?? track.id,
artistName: trackData['artists'] as String? ?? track.artistName, name: trackData['name'] as String? ?? track.name,
albumName: trackData['album_name'] as String? ?? track.albumName, artistName: trackData['artists'] as String? ?? track.artistName,
albumArtist: trackData['album_artist'] as String?, albumName: trackData['album_name'] as String? ?? track.albumName,
coverUrl: coverUrl ?? track.coverUrl, albumArtist: trackData['album_artist'] as String?,
isrc: trackData['isrc'] as String? ?? track.isrc, coverUrl: coverUrl ?? track.coverUrl,
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, isrc: trackData['isrc'] as String? ?? track.isrc,
trackNumber: trackData['track_number'] as int? ?? track.trackNumber, duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
discNumber: trackData['disc_number'] as int? ?? track.discNumber, trackNumber:
releaseDate: trackData['release_date'] as String? ?? track.releaseDate, trackData['track_number'] as int? ?? track.trackNumber,
)); discNumber: trackData['disc_number'] as int? ?? track.discNumber,
releaseDate:
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s'); trackData['release_date'] as String? ?? track.releaseDate,
),
);
_log.d(
'Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s',
);
if (i < tracks.length - 1) { if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
} }
continue; continue;
} }
} }
enrichedTracks.add(track); enrichedTracks.add(track);
} }
_log.i('Enrichment complete: ${enrichedTracks.length} tracks'); _log.i('Enrichment complete: ${enrichedTracks.length} tracks');
return enrichedTracks; return enrichedTracks;
} }
@@ -136,8 +149,8 @@ class CsvImportService {
final headers = _parseLine(lines[startIdx]); final headers = _parseLine(lines[startIdx]);
final colMap = <String, int>{}; final colMap = <String, int>{};
for (int i = 0; i < headers.length; i++) { for (int i = 0; i < headers.length; i++) {
String h = _cleanValue(headers[i]).toLowerCase(); String h = _cleanValue(headers[i]).toLowerCase();
colMap[h] = i; colMap[h] = i;
} }
_log.d('CSV Headers: ${colMap.keys.toList()}'); _log.d('CSV Headers: ${colMap.keys.toList()}');
@@ -147,48 +160,67 @@ class CsvImportService {
if (line.isEmpty) continue; if (line.isEmpty) continue;
final values = _parseLine(line); final values = _parseLine(line);
String? getVal(List<String> keys) { String? getVal(List<String> keys) {
return _getValue(values, colMap, keys); return _getValue(values, colMap, keys);
} }
String? trackName = getVal(['track name', 'track', 'name', 'title']); String? trackName = getVal(['track name', 'track', 'name', 'title']);
String? artistName = getVal(['artist name(s)', 'artist name', 'artist', 'artists']); String? artistName = getVal([
'artist name(s)',
'artist name',
'artist',
'artists',
]);
String? albumName = getVal(['album name', 'album']); String? albumName = getVal(['album name', 'album']);
String? isrc = getVal(['isrc']); String? isrc = getVal(['isrc']);
String? spotifyId = getVal(['track uri', 'spotify - id', 'spotify id', 'spotify_id', 'id', 'uri']); String? spotifyId = getVal([
'track uri',
'spotify - id',
'spotify id',
'spotify_id',
'id',
'uri',
]);
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) { if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.replaceAll('spotify:track:', ''); spotifyId = spotifyId.replaceAll('spotify:track:', '');
} }
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) { if ((trackName != null && trackName.isNotEmpty && artistName != null) ||
tracks.add(Track( (spotifyId != null && spotifyId.isNotEmpty)) {
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i', tracks.add(
name: trackName ?? 'Unknown Track', Track(
artistName: artistName ?? 'Unknown Artist', id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
albumName: albumName ?? 'Unknown Album', name: trackName ?? 'Unknown Track',
isrc: isrc, artistName: artistName ?? 'Unknown Artist',
duration: 0, // Will be updated by enrichment later albumName: albumName ?? 'Unknown Album',
coverUrl: null, // Will be fetched by enrichment isrc: isrc,
)); duration: 0, // Will be updated by enrichment later
coverUrl: null, // Will be fetched by enrichment
),
);
} }
} }
_log.i('Parsed ${tracks.length} tracks from CSV'); _log.i('Parsed ${tracks.length} tracks from CSV');
return tracks; return tracks;
} }
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) { static String? _getValue(
for (final key in possibleKeys) { List<String> values,
if (colMap.containsKey(key)) { Map<String, int> colMap,
final index = colMap[key]!; List<String> possibleKeys,
if (index < values.length) { ) {
return _cleanValue(values[index]); for (final key in possibleKeys) {
} if (colMap.containsKey(key)) {
} final index = colMap[key]!;
if (index < values.length) {
return _cleanValue(values[index]);
}
} }
return null; }
return null;
} }
static String _cleanValue(String val) { static String _cleanValue(String val) {
@@ -201,30 +233,29 @@ class CsvImportService {
} }
static List<String> _parseLine(String line) { static List<String> _parseLine(String line) {
final List<String> result = []; final List<String> result = [];
bool inQuote = false; bool inQuote = false;
StringBuffer buffer = StringBuffer(); var buffer = StringBuffer();
for (int i=0; i<line.length; i++) { for (int i = 0; i < line.length; i++) {
String char = line[i]; final char = line[i];
if (char == '"') { if (char == '"') {
if (i + 1 < line.length && line[i+1] == '"') { if (inQuote && i + 1 < line.length && line[i + 1] == '"') {
buffer.write('"'); buffer.write('"');
buffer.write('"'); i++;
i++; // Skip next quote char loop } else {
buffer.write('"'); // Write 2nd quote inQuote = !inQuote;
} else { }
inQuote = !inQuote; continue;
buffer.write(char); }
} if (char == ',' && !inQuote) {
} else if (char == ',' && !inQuote) { result.add(buffer.toString());
result.add(buffer.toString()); buffer = StringBuffer();
buffer.clear(); continue;
} else { }
buffer.write(char); buffer.write(char);
} }
} result.add(buffer.toString());
result.add(buffer.toString()); return result;
return result;
} }
} }
+25 -2
View File
@@ -10,6 +10,8 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg'); final _log = AppLogger('FFmpeg');
class FFmpegService { class FFmpegService {
static const int _commandLogPreviewLength = 300;
static String _buildOutputPath(String inputPath, String extension) { static String _buildOutputPath(String inputPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
final inputFile = File(inputPath); final inputFile = File(inputPath);
@@ -26,6 +28,25 @@ class FFmpegService {
return outputPath; return outputPath;
} }
static String _previewCommandForLog(String command) {
final redacted = command
.replaceAll(
RegExp(r'-metadata\s+lyrics="[^"]*"', caseSensitive: false),
'-metadata lyrics="<redacted>"',
)
.replaceAll(
RegExp(r'-metadata\s+unsyncedlyrics="[^"]*"', caseSensitive: false),
'-metadata unsyncedlyrics="<redacted>"',
)
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (redacted.length <= _commandLogPreviewLength) {
return redacted;
}
return '${redacted.substring(0, _commandLogPreviewLength)}...';
}
static Future<FFmpegResult> _execute(String command) async { static Future<FFmpegResult> _execute(String command) async {
try { try {
final session = await FFmpegKit.execute(command); final session = await FFmpegKit.execute(command);
@@ -280,7 +301,7 @@ class FFmpegService {
cmdBuffer.write('"$tempOutput" -y'); cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString(); final command = cmdBuffer.toString();
_log.d('Executing FFmpeg command: $command'); _log.d('Executing FFmpeg command: ${_previewCommandForLog(command)}');
final result = await _execute(command); final result = await _execute(command);
@@ -359,7 +380,9 @@ class FFmpegService {
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y'); cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString(); final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command'); _log.d(
'Executing FFmpeg MP3 embed command: ${_previewCommandForLog(command)}',
);
final result = await _execute(command); final result = await _execute(command);
+31 -31
View File
@@ -30,7 +30,7 @@ class NotificationService {
iOS: iosSettings, iOS: iosSettings,
); );
await _notifications.initialize(initSettings); await _notifications.initialize(settings: initSettings);
if (Platform.isAndroid) { if (Platform.isAndroid) {
await _notifications await _notifications
@@ -90,10 +90,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
downloadProgressId, id: downloadProgressId,
'Downloading $trackName', title: 'Downloading $trackName',
'$artistName$percentage%', body: '$artistName$percentage%',
details, notificationDetails: details,
); );
} }
@@ -133,10 +133,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
downloadProgressId, id: downloadProgressId,
'Finalizing $trackName', title: 'Finalizing $trackName',
'$artistName • Embedding metadata...', body: '$artistName • Embedding metadata...',
details, notificationDetails: details,
); );
} }
@@ -183,10 +183,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
downloadProgressId, id: downloadProgressId,
title, title: title,
'$trackName - $artistName', body: '$trackName - $artistName',
details, notificationDetails: details,
); );
} }
@@ -223,15 +223,15 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
downloadProgressId, id: downloadProgressId,
title, title: title,
'$completedCount tracks downloaded successfully', body: '$completedCount tracks downloaded successfully',
details, notificationDetails: details,
); );
} }
Future<void> cancelDownloadNotification() async { Future<void> cancelDownloadNotification() async {
await _notifications.cancel(downloadProgressId); await _notifications.cancel(id: downloadProgressId);
} }
Future<void> showUpdateDownloadProgress({ Future<void> showUpdateDownloadProgress({
@@ -274,10 +274,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
updateDownloadId, id: updateDownloadId,
'Downloading SpotiFLAC v$version', title: 'Downloading SpotiFLAC v$version',
'$receivedMB / $totalMB MB • $percentage%', body: '$receivedMB / $totalMB MB • $percentage%',
details, notificationDetails: details,
); );
} }
@@ -307,10 +307,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
updateDownloadId, id: updateDownloadId,
'Update Ready', title: 'Update Ready',
'SpotiFLAC v$version downloaded. Tap to install.', body: 'SpotiFLAC v$version downloaded. Tap to install.',
details, notificationDetails: details,
); );
} }
@@ -339,14 +339,14 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
updateDownloadId, id: updateDownloadId,
'Update Failed', title: 'Update Failed',
'Could not download update. Try again later.', body: 'Could not download update. Try again later.',
details, notificationDetails: details,
); );
} }
Future<void> cancelUpdateNotification() async { Future<void> cancelUpdateNotification() async {
await _notifications.cancel(updateDownloadId); await _notifications.cancel(id: updateDownloadId);
} }
} }
-90
View File
@@ -1,90 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
/// Service for extracting dominant colors from images
/// Uses caching to avoid re-extraction and small image size for speed
class PaletteService {
static final PaletteService instance = PaletteService._();
PaletteService._();
final Map<String, Color> _colorCache = {};
/// Extract dominant color from a network image URL
/// Uses small image size and limited colors for speed
Future<Color?> extractDominantColor(String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
return null;
}
final cached = _colorCache[imageUrl];
if (cached != null) {
return cached;
}
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(imageUrl),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[imageUrl] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService error: $e');
return null;
}
}
Future<Color?> extractDominantColorFromFile(String? filePath) async {
if (filePath == null || filePath.isEmpty) return null;
final cached = _colorCache[filePath];
if (cached != null) {
return cached;
}
try {
final file = File(filePath);
if (!await file.exists()) return null;
final paletteGenerator = await PaletteGenerator.fromImageProvider(
FileImage(file),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[filePath] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService file error: $e');
return null;
}
}
void clearCache() {
_colorCache.clear();
}
Color? getCached(String? imageUrl) {
if (imageUrl == null) return null;
return _colorCache[imageUrl];
}
}
+89 -49
View File
@@ -7,6 +7,15 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
const int _maxLogMessageLength = 500;
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
if (value.length <= maxLength) {
return value;
}
return '${value.substring(0, maxLength)}...[truncated]';
}
class LogEntry { class LogEntry {
final DateTime timestamp; final DateTime timestamp;
final String level; final String level;
@@ -46,10 +55,11 @@ class LogBuffer extends ChangeNotifier {
LogBuffer._internal(); LogBuffer._internal();
static const int maxEntries = 500; static const int maxEntries = 500;
static const Duration _goLogPollingInterval = Duration(milliseconds: 800);
final Queue<LogEntry> _entries = Queue<LogEntry>(); final Queue<LogEntry> _entries = Queue<LogEntry>();
Timer? _goLogTimer; Timer? _goLogTimer;
int _lastGoLogIndex = 0; int _lastGoLogIndex = 0;
static bool _loggingEnabled = false; static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled; static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) { static set loggingEnabled(bool value) {
@@ -68,17 +78,33 @@ class LogBuffer extends ChangeNotifier {
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') { if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
return; return;
} }
final sanitizedMessage = _truncateLogText(entry.message);
final sanitizedError = entry.error != null
? _truncateLogText(entry.error!)
: null;
final sanitizedEntry =
(sanitizedMessage == entry.message && sanitizedError == entry.error)
? entry
: LogEntry(
timestamp: entry.timestamp,
level: entry.level,
tag: entry.tag,
message: sanitizedMessage,
error: sanitizedError,
isFromGo: entry.isFromGo,
);
if (_entries.length >= maxEntries) { if (_entries.length >= maxEntries) {
_entries.removeFirst(); _entries.removeFirst();
} }
_entries.add(entry); _entries.add(sanitizedEntry);
notifyListeners(); notifyListeners();
} }
void startGoLogPolling() { void startGoLogPolling() {
_goLogTimer?.cancel(); _goLogTimer?.cancel();
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { _goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
await _fetchGoLogs(); await _fetchGoLogs();
}); });
} }
@@ -93,13 +119,13 @@ class LogBuffer extends ChangeNotifier {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex); final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
final logs = result['logs'] as List<dynamic>? ?? []; final logs = result['logs'] as List<dynamic>? ?? [];
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex; final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
for (final log in logs) { for (final log in logs) {
final timestamp = log['timestamp'] as String? ?? ''; final timestamp = log['timestamp'] as String? ?? '';
final level = log['level'] as String? ?? 'INFO'; final level = log['level'] as String? ?? 'INFO';
final tag = log['tag'] as String? ?? 'Go'; final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? ''; final message = log['message'] as String? ?? '';
DateTime parsedTime = DateTime.now(); DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) { if (timestamp.isNotEmpty) {
try { try {
@@ -107,25 +133,29 @@ class LogBuffer extends ChangeNotifier {
if (parts.length >= 3) { if (parts.length >= 3) {
final secParts = parts[2].split('.'); final secParts = parts[2].split('.');
parsedTime = DateTime( parsedTime = DateTime(
parsedTime.year, parsedTime.month, parsedTime.day, parsedTime.year,
int.parse(parts[0]), int.parse(parts[1]), parsedTime.month,
parsedTime.day,
int.parse(parts[0]),
int.parse(parts[1]),
int.parse(secParts[0]), int.parse(secParts[0]),
secParts.length > 1 ? int.parse(secParts[1]) : 0, secParts.length > 1 ? int.parse(secParts[1]) : 0,
); );
} }
} catch (_) { } catch (_) {}
}
} }
add(LogEntry( add(
timestamp: parsedTime, LogEntry(
level: level, timestamp: parsedTime,
tag: tag, level: level,
message: message, tag: tag,
isFromGo: true, message: message,
)); isFromGo: true,
),
);
} }
_lastGoLogIndex = nextIndex; _lastGoLogIndex = nextIndex;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
@@ -156,27 +186,31 @@ class LogBuffer extends ChangeNotifier {
Future<String> exportWithDeviceInfo() async { Future<String> exportWithDeviceInfo() async {
final buffer = StringBuffer(); final buffer = StringBuffer();
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln('SPOTIFLAC LOG EXPORT'); buffer.writeln('SPOTIFLAC LOG EXPORT');
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln(); buffer.writeln();
buffer.writeln('--- App Information ---'); buffer.writeln('--- App Information ---');
buffer.writeln('App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})'); buffer.writeln(
'App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})',
);
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}'); buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
buffer.writeln(); buffer.writeln();
buffer.writeln('--- Device Information ---'); buffer.writeln('--- Device Information ---');
try { try {
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) { if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo; final android = await deviceInfo.androidInfo;
buffer.writeln('Platform: Android'); buffer.writeln('Platform: Android');
buffer.writeln('Device: ${android.manufacturer} ${android.model}'); buffer.writeln('Device: ${android.manufacturer} ${android.model}');
buffer.writeln('Brand: ${android.brand}'); buffer.writeln('Brand: ${android.brand}');
buffer.writeln('Android Version: ${android.version.release} (SDK ${android.version.sdkInt})'); buffer.writeln(
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
);
buffer.writeln('Device ID: ${android.id}'); buffer.writeln('Device ID: ${android.id}');
buffer.writeln('Hardware: ${android.hardware}'); buffer.writeln('Hardware: ${android.hardware}');
buffer.writeln('Product: ${android.product}'); buffer.writeln('Product: ${android.product}');
@@ -196,16 +230,16 @@ class LogBuffer extends ChangeNotifier {
buffer.writeln('Failed to get device info: $e'); buffer.writeln('Failed to get device info: $e');
} }
buffer.writeln(); buffer.writeln();
buffer.writeln('--- Log Summary ---'); buffer.writeln('--- Log Summary ---');
buffer.writeln('Total Entries: ${_entries.length}'); buffer.writeln('Total Entries: ${_entries.length}');
int errorCount = 0; int errorCount = 0;
int warnCount = 0; int warnCount = 0;
int infoCount = 0; int infoCount = 0;
int debugCount = 0; int debugCount = 0;
int goCount = 0; int goCount = 0;
for (final entry in _entries) { for (final entry in _entries) {
switch (entry.level) { switch (entry.level) {
case 'ERROR': case 'ERROR':
@@ -224,23 +258,23 @@ class LogBuffer extends ChangeNotifier {
} }
if (entry.isFromGo) goCount++; if (entry.isFromGo) goCount++;
} }
buffer.writeln('Errors: $errorCount'); buffer.writeln('Errors: $errorCount');
buffer.writeln('Warnings: $warnCount'); buffer.writeln('Warnings: $warnCount');
buffer.writeln('Info: $infoCount'); buffer.writeln('Info: $infoCount');
buffer.writeln('Debug: $debugCount'); buffer.writeln('Debug: $debugCount');
buffer.writeln('From Go Backend: $goCount'); buffer.writeln('From Go Backend: $goCount');
buffer.writeln(); buffer.writeln();
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln('LOG ENTRIES'); buffer.writeln('LOG ENTRIES');
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln(); buffer.writeln();
for (final entry in _entries) { for (final entry in _entries) {
buffer.writeln(entry.toString()); buffer.writeln(entry.toString());
} }
return buffer.toString(); return buffer.toString();
} }
@@ -274,19 +308,21 @@ class BufferedOutput extends LogOutput {
void output(OutputEvent event) { void output(OutputEvent event) {
if (kDebugMode) { if (kDebugMode) {
for (final line in event.lines) { for (final line in event.lines) {
debugPrint(line); debugPrint(_truncateLogText(line));
} }
} }
final level = _levelToString(event.level); final level = _levelToString(event.level);
final message = event.lines.join('\n'); final message = _truncateLogText(event.lines.join('\n'));
LogBuffer().add(LogEntry( LogBuffer().add(
timestamp: DateTime.now(), LogEntry(
level: level, timestamp: DateTime.now(),
tag: tag, level: level,
message: message, tag: tag,
)); message: message,
),
);
} }
String _levelToString(Level level) { String _levelToString(Level level) {
@@ -336,13 +372,15 @@ class AppLogger {
} }
void _addToBuffer(String level, String message, {String? error}) { void _addToBuffer(String level, String message, {String? error}) {
LogBuffer().add(LogEntry( LogBuffer().add(
timestamp: DateTime.now(), LogEntry(
level: level, timestamp: DateTime.now(),
tag: _tag, level: level,
message: message, tag: _tag,
error: error, message: message,
)); error: error,
),
);
} }
void d(String message) { void d(String message) {
@@ -373,7 +411,9 @@ class AppLogger {
if (error != null) { if (error != null) {
_addToBuffer('ERROR', message, error: error.toString()); _addToBuffer('ERROR', message, error: error.toString());
if (kDebugMode) { if (kDebugMode) {
debugPrint('[$_tag] ERROR: $message | $error'); debugPrint(
'[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}',
);
if (stackTrace != null) { if (stackTrace != null) {
debugPrint(stackTrace.toString()); debugPrint(stackTrace.toString());
} }
+28 -36
View File
@@ -189,10 +189,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: connectivity_plus name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5" version: "7.0.0"
connectivity_plus_platform_interface: connectivity_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -386,34 +386,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" sha256: "76cd20bcfa72fabe50ea27eeaf165527f446f55d3033021462084b87805b4cac"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "19.5.0" version: "20.0.0"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "7.0.0"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.1.0" version: "10.0.0"
flutter_local_notifications_windows: flutter_local_notifications_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_windows name: flutter_local_notifications_windows
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" sha256: "7ddd964fa85b6a23e96956c5b63ef55cdb9e5947b71b95712204db42ad46da61"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "2.0.0"
flutter_localizations: flutter_localizations:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -439,50 +439,50 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_secure_storage name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.2.4" version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux: flutter_secure_storage_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_linux name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.3" version: "3.0.0"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface: flutter_secure_storage_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_platform_interface name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "2.0.1"
flutter_secure_storage_web: flutter_secure_storage_web:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_web name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "2.1.0"
flutter_secure_storage_windows: flutter_secure_storage_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_windows name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "4.1.0"
flutter_svg: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -741,14 +741,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
palette_generator:
dependency: "direct main"
description:
name: palette_generator
sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed"
url: "https://pub.dev"
source: hosted
version: "0.3.3+7"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
+4 -5
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.5.0+74 version: 3.5.1+75
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -24,7 +24,7 @@ dependencies:
# Storage & Persistence # Storage & Persistence
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
flutter_secure_storage: ^9.2.2 flutter_secure_storage: 10.0.0
path_provider: ^2.1.5 path_provider: ^2.1.5
path: ^1.9.0 path: ^1.9.0
sqflite: ^2.4.1 sqflite: ^2.4.1
@@ -32,7 +32,7 @@ dependencies:
# HTTP & Network # HTTP & Network
http: ^1.6.0 http: ^1.6.0
dio: ^5.8.0 dio: ^5.8.0
connectivity_plus: ^6.0.3 connectivity_plus: 7.0.0
# UI Components # UI Components
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
@@ -43,7 +43,6 @@ dependencies:
# Material Expressive 3 / Dynamic Color # Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1 material_color_utilities: ^0.11.1
palette_generator: ^0.3.3+4
# Permissions # Permissions
permission_handler: ^12.0.1 permission_handler: ^12.0.1
@@ -66,7 +65,7 @@ dependencies:
open_filex: ^4.7.0 open_filex: ^4.7.0
# Notifications # Notifications
flutter_local_notifications: ^19.0.0 flutter_local_notifications: 20.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"baseBranches": ["dev"],
"ignoreDeps": ["dev.flutter.flutter-plugin-loader"],
"automerge": true,
"automergeType": "pr",
"packageRules": [
{
"matchUpdateTypes": ["major"],
"automerge": false
},
{
"matchManagers": ["pub"],
"groupName": "Flutter dependencies"
},
{
"matchManagers": ["gomod"],
"groupName": "Go dependencies"
},
{
"matchManagers": ["gradle"],
"groupName": "Gradle dependencies"
}
]
}