mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +02:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67fc3e5de2 | |||
| f1e6e9253f | |||
| 11c612e270 | |||
| cec5e49659 | |||
| 1dbdb5f2c3 | |||
| 086511d3e9 | |||
| 3d366d21b7 | |||
| 35f412dbd2 | |||
| c167aa0522 | |||
| fccb3f3d78 | |||
| 3a33283e94 | |||
| c74fb28a3a | |||
| ea504cc3ed | |||
| 61a2ad258e | |||
| ab62a8b1a9 | |||
| 479eb1272d | |||
| d23562e579 | |||
| 541d64bdd0 | |||
| d4f7e6e494 | |||
| 532c08fe2e | |||
| 704b9674f4 | |||
| 3de94280d2 | |||
| 65897789f6 | |||
| 5d097c3a95 | |||
| 4023e752a0 | |||
| 9a722b1a24 | |||
| 37b4727a29 | |||
| 2604d0002a | |||
| cca337ab31 | |||
| bb6e766a09 | |||
| 01cbdde70e | |||
| e70ed311ed | |||
| c732cddf06 | |||
| 1f71f957e2 | |||
| 757c5fab19 | |||
| cfa537db1f | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| 423695c24d |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
|
[](https://www.virustotal.com/gui/file/f6ba8fa4a572d69f6196f980733089cb741088e3ceb49d0bd3ceda5a694a2466/)
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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>[
|
||||||
|
|||||||
@@ -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],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user