mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
56 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 | |||
| 8b18bef5ab | |||
| 76b01fb837 | |||
| 219ea593dd | |||
| 5c54e04b69 | |||
| bef07b1583 | |||
| 859762e35c | |||
| ca136b8e17 | |||
| 03d29a73f7 | |||
| c6ee9cda35 | |||
| ad3fefac0b | |||
| ad606cca53 | |||
| c0a9cb756f | |||
| 5fa00c0051 | |||
| 239e073a8c | |||
| 278ebf3472 | |||
| 7ade57e010 | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| 65a152cada | |||
| 423695c24d |
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
|
||||
@@ -60,23 +60,23 @@ jobs:
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
ls -la
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: android-apk
|
||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||
@@ -169,17 +169,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ios/Pods
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Extract changelog for version
|
||||
id: changelog
|
||||
@@ -338,13 +338,13 @@ jobs:
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
@@ -385,7 +385,7 @@ jobs:
|
||||
cat /tmp/release_body.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
@@ -403,16 +403,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
+180
-3
@@ -1,5 +1,182 @@
|
||||
# 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
|
||||
|
||||
### Highlights
|
||||
|
||||
- **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs)
|
||||
- Select download folder via SAF tree picker
|
||||
- Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths
|
||||
- Works around Android 10+ scoped storage permission errors
|
||||
- **Modern Onboarding Experience**: Completely redesigned Setup and Tutorial screens
|
||||
|
||||
### Added
|
||||
|
||||
- Home feed disk caching via SharedPreferences for instant restore on app startup
|
||||
- SAF display path resolver in native Android layer (converts tree URIs to readable paths)
|
||||
- New settings fields for storage mode + SAF tree URI
|
||||
- SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF
|
||||
- SAF library scan mode (DocumentFile traversal + metadata read)
|
||||
- Incremental library scanning for filesystem and SAF paths (only scans new/modified files and detects removed files)
|
||||
- Force Full Scan action in Library Settings to rescan all files on demand
|
||||
- Downloaded files are now excluded from Local Library scan results to prevent duplicate entries
|
||||
- Legacy library rows now support `file_mod_time` backfill before incremental scans (faster follow-up scans after upgrade)
|
||||
- Library UI toggle to show SAF-repaired history items
|
||||
- Scan cancelled banner + retry action for library scans
|
||||
- Android DocumentFile dependency for SAF operations
|
||||
- Post-processing API v2 (SAF-aware, ready to replace v1)
|
||||
- Donate page in Settings with Ko-fi and Buy Me a Coffee links
|
||||
- Per-App Language support on Android 13+ (locale_config.xml)
|
||||
- Interactive tutorial with working search bar simulation and clickable download buttons
|
||||
- Tutorial completion state is persisted after onboarding
|
||||
- Visual feedback animations for page transitions, entrance effects, and feature lists
|
||||
- New dedicated welcome step in setup wizard with improved branding
|
||||
|
||||
### Changed
|
||||
|
||||
- Download pipeline supports `output_path` + `output_ext` for Go backend
|
||||
- Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled
|
||||
- Post-processing hooks run for SAF content URIs (via temp file bridge)
|
||||
- File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`)
|
||||
- Local Library scan defaults to incremental mode; full rescan is available via Force Full Scan
|
||||
- Local library database upgraded to schema v3 with `file_mod_time` tracking for incremental scan cache
|
||||
- Platform channels expanded with incremental scan APIs (`scanLibraryFolderIncremental`) on Android and iOS
|
||||
- Android platform channel adds `getSafFileModTimes` for SAF legacy cache backfill
|
||||
- Android build tooling upgraded to Gradle 9.3.1 (wrapper)
|
||||
- Android build path validated with Java 25 (Gradle/Kotlin/assemble debug)
|
||||
- SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`)
|
||||
- `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility
|
||||
- Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed
|
||||
- Setup screen UI polish: smaller logo, thin outline borders on text fields
|
||||
- Removed support section from About page (moved to Donate page)
|
||||
- Qobuz squid.wtf region fallback for blocked regions
|
||||
- Setup screen converted to PageView flow with animated progress bar and modern card layouts
|
||||
- Tutorial screen aligned with Setup Screen design, updated typography and softened UI shapes
|
||||
- Larger, more accessible navigation buttons for onboarding flow
|
||||
- Reduced visual noise by removing unnecessary glow effects
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android 10+ `permission denied` when writing to `/storage/emulated/0` (now handled via SAF)
|
||||
- SAF history repair: auto-resolve missing content URIs using tree + filename
|
||||
- SAF download fallback: retry in app-private storage when SAF write fails
|
||||
- Tidal DASH manifest writing when output path is a file descriptor (no invalid `.m4a` path)
|
||||
- External LRC output in SAF mode
|
||||
- Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices)
|
||||
- Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support
|
||||
- SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling
|
||||
- SAF share file now copies to temp before sharing (fixes share from SAF content URI)
|
||||
- Home feed not updating after installing extension with homeFeed capability (no longer requires app restart)
|
||||
- Library scan hero card showing 0 tracks during scan (now shows scanned file count in real-time)
|
||||
- Library folder picker no longer requires MANAGE_EXTERNAL_STORAGE on Android 10+ (uses SAF tree picker)
|
||||
- One-time SAF migration prompt for users updating from pre-SAF versions
|
||||
- Fixed `fileModTime` propagation across Go/Android/Dart so incremental scan cache is stored and reused correctly
|
||||
- Fixed SAF incremental scan key mismatch (`lastModified` vs `fileModTime`) and normalized result fields (`skippedCount`, `totalFiles`)
|
||||
- Fixed incremental scan progress when all files are skipped (`scanned_files` now reaches total files)
|
||||
- Removed duplicate `"removeExtension"` branch in Android method channel handler (eliminates Kotlin duplicate-branch warning)
|
||||
|
||||
---
|
||||
|
||||
## [3.4.2] - 2026-02-04
|
||||
|
||||
### Improved
|
||||
|
||||
- **Mobile Network Reliability**: All providers (Qobuz, Tidal, Amazon, Deezer) now have retry logic with exponential backoff
|
||||
- Increased API timeouts: 15s → 25s (Deezer, Qobuz, Tidal), 30s (Amazon)
|
||||
- Up to 3 retry attempts per API call (500ms → 1s → 2s backoff)
|
||||
- Retryable: timeout, connection reset/refused, EOF, HTTP 5xx, HTTP 429
|
||||
- **SongLink ID Extraction**: Extract QobuzID/TidalID directly from SongLink URLs
|
||||
- New fields in `TrackAvailability`: `QobuzID`, `TidalID`
|
||||
- Qobuz/Tidal now use direct Track ID from SongLink instead of re-parsing URLs
|
||||
- **Qobuz Download Flow**: New Strategy 3 - get QobuzID from SongLink before ISRC search
|
||||
- Cache hit now uses `GetTrackByID()` directly instead of searching again
|
||||
- Pre-warm cache tries SongLink first before direct ISRC search
|
||||
- **Tidal Download Flow**: Use `availability.TidalID` directly from SongLink struct
|
||||
|
||||
---
|
||||
|
||||
## [3.4.1] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- Metadata Priority order now persists after app restart
|
||||
- Download Provider Priority order now persists after app restart
|
||||
|
||||
---
|
||||
|
||||
## [3.4.0] - 2026-02-03
|
||||
|
||||
### Highlights
|
||||
@@ -78,7 +255,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
|
||||
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
|
||||
- **Album/Playlist Search**: Deezer search now includes albums and playlists
|
||||
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re*Index.(ot_inc))
|
||||
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re\*Index.(ot_inc))
|
||||
- **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed
|
||||
- **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks
|
||||
|
||||
@@ -198,7 +375,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model
|
||||
- Metadata is stored in download history and persists across app restarts
|
||||
- New localization strings: `trackGenre`, `trackLabel`, `trackCopyright`
|
||||
- `**utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
|
||||
- `**utils.randomUserAgent()` for Extensions\*\*: New utility function for extensions to get random browser User-Agent strings
|
||||
- Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0`
|
||||
- Useful for extensions that need to rotate User-Agents to avoid detection
|
||||
|
||||
@@ -495,4 +672,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
||||
|
||||
---
|
||||
|
||||
*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[](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)
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -96,11 +96,13 @@ repositories {
|
||||
}
|
||||
|
||||
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
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="es-ES" />
|
||||
<locale android:name="id" />
|
||||
<locale android:name="pt-PT" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="tr" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="hi" />
|
||||
<locale android:name="ko" />
|
||||
<locale android:name="nl" />
|
||||
<locale android:name="zh" />
|
||||
</locale-config>
|
||||
@@ -22,7 +22,7 @@ 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 {
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
+171
-82
@@ -17,6 +17,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Amazon API timeout and retry configuration for mobile networks
|
||||
const (
|
||||
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
|
||||
amazonMaxRetries = 2 // Number of retry attempts
|
||||
amazonRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
}
|
||||
@@ -36,15 +43,6 @@ type AfkarXYZResponse struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func amazonIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
@@ -54,12 +52,50 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||
return globalAmazonDownloader
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
|
||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
|
||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
|
||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
|
||||
if !isRetryable {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// doAfkarXYZRequest performs a single request to AfkarXYZ API
|
||||
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -98,12 +134,22 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
fileName = reg.ReplaceAllString(fileName, "")
|
||||
|
||||
GoLog("[Amazon] AfkarXYZ returned: %s (%.2f MB)\n", fileName, float64(apiResp.Data.FileSize)/(1024*1024))
|
||||
|
||||
return apiResp.Data.DirectLink, fileName, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||
|
||||
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||
return downloadURL, fileName, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
@@ -142,7 +188,7 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -161,23 +207,23 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
@@ -197,44 +243,63 @@ type AmazonDownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
amazonURL := ""
|
||||
if req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
||||
amazonURL = cached.AmazonURL
|
||||
GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
if amazonURL == "" {
|
||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
amazonURL = availability.AmazonURL
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
if req.OutputDir != "." {
|
||||
if !isSafOutput && req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download using AfkarXYZ API
|
||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
|
||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||
}
|
||||
@@ -249,11 +314,18 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
@@ -273,7 +345,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}()
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
@@ -346,59 +418,70 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
if isSafOutput {
|
||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
quality := AudioQuality{}
|
||||
if isSafOutput {
|
||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
quality, err = GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to ISRC index for fast duplicate checking
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
bitDepth := 0
|
||||
sampleRate := 0
|
||||
@@ -407,6 +490,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
@@ -418,5 +506,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -430,11 +430,12 @@ func extendedHeaderSize(data []byte, version byte) int {
|
||||
return 0
|
||||
}
|
||||
var size int
|
||||
if version == 3 {
|
||||
switch version {
|
||||
case 3:
|
||||
size = int(binary.BigEndian.Uint32(data[:4]))
|
||||
} else if version == 4 {
|
||||
case 4:
|
||||
size = syncsafeToInt(data[:4])
|
||||
} else {
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
if size <= 0 {
|
||||
@@ -624,14 +625,6 @@ func readOggPageWithHeader(file *os.File) (*oggPage, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readOggPage(file *os.File) ([]byte, error) {
|
||||
page, err := readOggPageWithHeader(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return page.data, nil
|
||||
}
|
||||
|
||||
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
|
||||
const maxPacketSize = 10 * 1024 * 1024
|
||||
var packets [][]byte
|
||||
@@ -930,11 +923,12 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||
}
|
||||
|
||||
var frameSize int
|
||||
if majorVersion == 2 {
|
||||
switch majorVersion {
|
||||
case 2:
|
||||
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
|
||||
} else if majorVersion == 4 {
|
||||
case 4:
|
||||
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
|
||||
} else {
|
||||
default:
|
||||
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
|
||||
}
|
||||
|
||||
|
||||
+42
-1
@@ -23,6 +23,11 @@ const (
|
||||
deezerCacheTTL = 10 * time.Minute
|
||||
|
||||
deezerMaxParallelISRC = 10
|
||||
|
||||
// Deezer API timeout and retry configuration for mobile networks
|
||||
deezerAPITimeoutMobile = 25 * time.Second
|
||||
deezerMaxRetries = 2
|
||||
deezerRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type DeezerClient struct {
|
||||
@@ -42,7 +47,7 @@ var (
|
||||
func GetDeezerClient() *DeezerClient {
|
||||
deezerClientOnce.Do(func() {
|
||||
deezerClient = &DeezerClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
httpClient: NewHTTPClientWithTimeout(deezerAPITimeoutMobile),
|
||||
searchCache: make(map[string]*cacheEntry),
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
artistCache: make(map[string]*cacheEntry),
|
||||
@@ -992,6 +997,42 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
|
||||
}
|
||||
|
||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
err := c.doGetJSON(ctx, endpoint, dst)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
|
||||
if !isRetryable {
|
||||
return err
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+68
-2
@@ -128,6 +128,9 @@ type DownloadRequest struct {
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
OutputPath string `json:"output_path,omitempty"`
|
||||
OutputFD int `json:"output_fd,omitempty"`
|
||||
OutputExt string `json:"output_ext,omitempty"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
@@ -199,8 +202,10 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
req.OutputPath = strings.TrimSpace(req.OutputPath)
|
||||
req.OutputExt = strings.TrimSpace(req.OutputExt)
|
||||
|
||||
if req.OutputDir != "" {
|
||||
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
@@ -240,6 +245,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
@@ -257,6 +263,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = amazonErr
|
||||
@@ -336,8 +343,10 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
req.OutputPath = strings.TrimSpace(req.OutputPath)
|
||||
req.OutputExt = strings.TrimSpace(req.OutputExt)
|
||||
|
||||
if req.OutputDir != "" {
|
||||
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
@@ -402,6 +411,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||
@@ -421,6 +431,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||
@@ -569,6 +580,14 @@ func SetDownloadDirectory(path string) error {
|
||||
return setDownloadDir(path)
|
||||
}
|
||||
|
||||
// AllowDownloadDir adds a directory to the extension file sandbox allowlist.
|
||||
func AllowDownloadDir(path string) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return
|
||||
}
|
||||
AddAllowedDownloadDir(path)
|
||||
}
|
||||
|
||||
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
||||
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
||||
|
||||
@@ -1260,6 +1279,17 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||
return "", fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
req.OutputPath = strings.TrimSpace(req.OutputPath)
|
||||
req.OutputExt = strings.TrimSpace(req.OutputExt)
|
||||
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
result, err := DownloadWithExtensionFallback(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1958,6 +1988,35 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
||||
var metadata map[string]interface{}
|
||||
if metadataJSON != "" {
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||
metadata = make(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
var input PostProcessInput
|
||||
if inputJSON != "" {
|
||||
if err := json.Unmarshal([]byte(inputJSON), &input); err != nil {
|
||||
input = PostProcessInput{}
|
||||
}
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
result, err := manager.RunPostProcessingV2(input, metadata)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetPostProcessingProvidersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
providers := manager.GetPostProcessingProviders()
|
||||
@@ -2136,6 +2195,13 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) {
|
||||
return ScanLibraryFolder(folderPath)
|
||||
}
|
||||
|
||||
// ScanLibraryFolderIncrementalJSON performs an incremental library scan
|
||||
// existingFilesJSON: JSON object mapping filePath -> modTime (unix millis)
|
||||
// Returns IncrementalScanResult as JSON
|
||||
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
|
||||
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
||||
}
|
||||
|
||||
func GetLibraryScanProgressJSON() string {
|
||||
return GetLibraryScanProgress()
|
||||
}
|
||||
|
||||
@@ -1123,6 +1123,10 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
}
|
||||
|
||||
func buildOutputPath(req DownloadRequest) string {
|
||||
if strings.TrimSpace(req.OutputPath) != "" {
|
||||
return strings.TrimSpace(req.OutputPath)
|
||||
}
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -1138,7 +1142,14 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename)
|
||||
ext := strings.TrimSpace(req.OutputExt)
|
||||
if ext == "" {
|
||||
ext = ".flac"
|
||||
} else if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||
@@ -1340,11 +1351,21 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
type PostProcessResult struct {
|
||||
Success bool `json:"success"`
|
||||
NewFilePath string `json:"new_file_path,omitempty"`
|
||||
NewFileURI string `json:"new_file_uri,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
}
|
||||
|
||||
type PostProcessInput struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsSAF bool `json:"is_saf,omitempty"`
|
||||
}
|
||||
|
||||
const PostProcessTimeout = 2 * time.Minute
|
||||
|
||||
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
@@ -1409,6 +1430,75 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
return &postResult, nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
if !p.extension.Manifest.HasPostProcessing() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
filePath := input.Path
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined') {
|
||||
if (typeof extension.postProcessV2 === 'function') {
|
||||
return extension.postProcessV2(%s, %s, %q);
|
||||
}
|
||||
if (typeof extension.postProcess === 'function') {
|
||||
return extension.postProcess(%q, %s, %q);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if IsTimeoutError(err) {
|
||||
errMsg = "postProcess timeout: extension took too long to complete"
|
||||
}
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: errMsg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: "postProcess returned null",
|
||||
}, nil
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to marshal result: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var postResult PostProcessResult
|
||||
if err := json.Unmarshal(jsonBytes, &postResult); err != nil {
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to parse result: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &postResult, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -1531,3 +1621,58 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
||||
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
||||
}
|
||||
|
||||
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
|
||||
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||
providers := m.GetPostProcessingProviders()
|
||||
if len(providers) == 0 {
|
||||
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
||||
}
|
||||
|
||||
currentInput := input
|
||||
for _, provider := range providers {
|
||||
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
||||
for _, hook := range hooks {
|
||||
if !hook.DefaultEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(currentInput.Path))
|
||||
if ext == "" && currentInput.Name != "" {
|
||||
ext = strings.ToLower(filepath.Ext(currentInput.Name))
|
||||
}
|
||||
if len(hook.SupportedFormats) > 0 && ext != "" {
|
||||
supported := false
|
||||
for _, format := range hook.SupportedFormats {
|
||||
if "."+format == ext || format == ext[1:] {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[PostProcessV2] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentInput.Path)
|
||||
|
||||
result, err := provider.PostProcessV2(currentInput, metadata, hook.ID)
|
||||
if err != nil {
|
||||
GoLog("[PostProcessV2] Hook %s failed: %v\n", hook.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if result.Success && result.NewFilePath != "" {
|
||||
currentInput.Path = result.NewFilePath
|
||||
if currentInput.Name == "" {
|
||||
currentInput.Name = filepath.Base(result.NewFilePath)
|
||||
}
|
||||
}
|
||||
if result.Success && result.NewFileURI != "" {
|
||||
currentInput.URI = result.NewFileURI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||
}
|
||||
|
||||
+5
-2
@@ -2,15 +2,18 @@ module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.25.6
|
||||
toolchain go1.25.7
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
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/v2 v2.0.2
|
||||
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
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -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/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/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/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/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/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
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/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-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/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
|
||||
@@ -20,6 +20,7 @@ type LibraryScanResult struct {
|
||||
FilePath string `json:"filePath"`
|
||||
CoverPath string `json:"coverPath,omitempty"`
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
@@ -40,6 +41,14 @@ type LibraryScanProgress struct {
|
||||
IsComplete bool `json:"is_complete"`
|
||||
}
|
||||
|
||||
// IncrementalScanResult contains results of an incremental library scan
|
||||
type IncrementalScanResult struct {
|
||||
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
||||
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
||||
SkippedCount int `json:"skippedCount"` // Files that were unchanged
|
||||
TotalFiles int `json:"totalFiles"` // Total files in folder
|
||||
}
|
||||
|
||||
var (
|
||||
libraryScanProgress LibraryScanProgress
|
||||
libraryScanProgressMu sync.RWMutex
|
||||
@@ -179,6 +188,11 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
Format: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
// Get file modification time
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
result.FileModTime = info.ModTime().UnixMilli()
|
||||
}
|
||||
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
@@ -413,3 +427,183 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||
// Only files that are new or have changed modification time will be scanned
|
||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||
if folderPath == "" {
|
||||
return "{}", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
|
||||
info, err := os.Stat(folderPath)
|
||||
if err != nil {
|
||||
return "{}", fmt.Errorf("folder not found: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||
}
|
||||
|
||||
// Parse existing files map
|
||||
existingFiles := make(map[string]int64)
|
||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||
|
||||
// Reset progress
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Setup cancellation
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
}
|
||||
libraryScanCancel = make(chan struct{})
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
// Collect all audio files with their mod times
|
||||
type fileInfo struct {
|
||||
path string
|
||||
modTime int64
|
||||
}
|
||||
var currentFiles []fileInfo
|
||||
currentPathSet := make(map[string]bool)
|
||||
|
||||
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if supportedAudioFormats[ext] {
|
||||
currentFiles = append(currentFiles, fileInfo{
|
||||
path: path,
|
||||
modTime: info.ModTime().UnixMilli(),
|
||||
})
|
||||
currentPathSet[path] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "{}", err
|
||||
}
|
||||
|
||||
totalFiles := len(currentFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Find files to scan (new or modified)
|
||||
var filesToScan []fileInfo
|
||||
skippedCount := 0
|
||||
|
||||
for _, f := range currentFiles {
|
||||
existingModTime, exists := existingFiles[f.path]
|
||||
if !exists {
|
||||
// New file
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else if f.modTime != existingModTime {
|
||||
// Modified file
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else {
|
||||
// Unchanged file - skip
|
||||
skippedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Find deleted files
|
||||
var deletedPaths []string
|
||||
for existingPath := range existingFiles {
|
||||
if !currentPathSet[existingPath] {
|
||||
deletedPaths = append(deletedPaths, existingPath)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
|
||||
len(filesToScan), skippedCount, len(deletedPaths))
|
||||
|
||||
if len(filesToScan) == 0 {
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = totalFiles
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgress.ProgressPct = 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result := IncrementalScanResult{
|
||||
Scanned: []LibraryScanResult{},
|
||||
DeletedPaths: deletedPaths,
|
||||
SkippedCount: skippedCount,
|
||||
TotalFiles: totalFiles,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Scan the files that need scanning
|
||||
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
for i, f := range filesToScan {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return "{}", fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = skippedCount + i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(f.path)
|
||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result, err := scanAudioFile(f.path, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ErrorCount = errorCount
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgress.ScannedFiles = totalFiles
|
||||
libraryScanProgress.ProgressPct = 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
|
||||
len(results), skippedCount, len(deletedPaths), errorCount)
|
||||
|
||||
scanResult := IncrementalScanResult{
|
||||
Scanned: results,
|
||||
DeletedPaths: deletedPaths,
|
||||
SkippedCount: skippedCount,
|
||||
TotalFiles: totalFiles,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(scanResult)
|
||||
if err != nil {
|
||||
return "{}", fmt.Errorf("failed to marshal results: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
+17
-2
@@ -22,6 +22,11 @@ type LogBuffer struct {
|
||||
loggingEnabled bool
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLogBufferSize = 500
|
||||
maxLogMessageLength = 500
|
||||
)
|
||||
|
||||
var (
|
||||
globalLogBuffer *LogBuffer
|
||||
logBufferOnce sync.Once
|
||||
@@ -30,14 +35,22 @@ var (
|
||||
func GetLogBuffer() *LogBuffer {
|
||||
logBufferOnce.Do(func() {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
entries: make([]LogEntry, 0, 1000),
|
||||
maxSize: 1000,
|
||||
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||
maxSize: defaultLogBufferSize,
|
||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||
}
|
||||
})
|
||||
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) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
@@ -58,6 +71,8 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
return
|
||||
}
|
||||
|
||||
message = truncateLogMessage(message)
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format("15:04:05.000"),
|
||||
Level: level,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isFDOutput(outputFD int) bool {
|
||||
return outputFD > 0
|
||||
}
|
||||
|
||||
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
||||
if isFDOutput(outputFD) {
|
||||
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
|
||||
}
|
||||
return os.Create(outputPath)
|
||||
}
|
||||
|
||||
func cleanupOutputOnError(outputPath string, outputFD int) {
|
||||
if isFDOutput(outputFD) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(outputPath)
|
||||
if path == "" || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
+58
-8
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
type TrackIDCacheEntry struct {
|
||||
TidalTrackID int64
|
||||
QobuzTrackID int64
|
||||
AmazonTrackID string
|
||||
AmazonURL string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -106,7 +107,7 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -115,7 +116,7 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
entry = &TrackIDCacheEntry{}
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.AmazonTrackID = trackID
|
||||
entry.AmazonURL = amazonURL
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
@@ -156,17 +157,20 @@ func FetchCoverAndLyricsParallel(
|
||||
) *ParallelDownloadResult {
|
||||
result := &ParallelDownloadResult{}
|
||||
var wg sync.WaitGroup
|
||||
var resultMu sync.Mutex
|
||||
|
||||
if coverURL != "" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.CoverErr = err
|
||||
} else {
|
||||
result.CoverData = data
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -177,6 +181,7 @@ func FetchCoverAndLyricsParallel(
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.LyricsErr = err
|
||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
@@ -185,6 +190,7 @@ func FetchCoverAndLyricsParallel(
|
||||
} else {
|
||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -211,6 +217,9 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, req := range requests {
|
||||
if req.ISRC == "" {
|
||||
continue
|
||||
}
|
||||
if cached := cache.Get(req.ISRC); cached != nil {
|
||||
continue
|
||||
}
|
||||
@@ -225,7 +234,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
case "tidal":
|
||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||
case "qobuz":
|
||||
preWarmQobuzCache(r.ISRC)
|
||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||
case "amazon":
|
||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||
}
|
||||
@@ -243,10 +252,30 @@ func preWarmTidalCache(isrc, _, _ string) {
|
||||
}
|
||||
}
|
||||
|
||||
func preWarmQobuzCache(isrc string) {
|
||||
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
|
||||
// 1. From SongLink (fast, no Qobuz API call needed)
|
||||
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
||||
if spotifyID != "" {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||
// Parse QobuzID to int64
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, trackID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Direct ISRC search on Qobuz API
|
||||
downloader := NewQobuzDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||
}
|
||||
}
|
||||
@@ -254,13 +283,34 @@ func preWarmQobuzCache(isrc string) {
|
||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.Amazon {
|
||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||
if err == nil && availability != nil && availability.AmazonURL != "" {
|
||||
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
|
||||
}
|
||||
}
|
||||
|
||||
func PreWarmCache(tracksJSON string) error {
|
||||
var requests []PreWarmCacheRequest
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Service string `json:"service"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||
return fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||
}
|
||||
|
||||
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||
for i, t := range tracks {
|
||||
requests[i] = PreWarmCacheRequest{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
SpotifyID: t.SpotifyID,
|
||||
Service: t.Service,
|
||||
}
|
||||
}
|
||||
|
||||
go PreWarmTrackCache(requests)
|
||||
return nil
|
||||
|
||||
+264
-100
@@ -380,6 +380,42 @@ func decodeXOR(data []byte) string {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return "", fmt.Errorf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
if success, ok := raw["success"].(bool); ok && !success {
|
||||
if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
return "", fmt.Errorf("api returned success=false")
|
||||
}
|
||||
|
||||
if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||
return strings.TrimSpace(linkVal), nil
|
||||
}
|
||||
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||
return strings.TrimSpace(linkVal), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||
formatID := mapJumoQuality(quality)
|
||||
region := "US"
|
||||
@@ -725,75 +761,151 @@ type qobuzAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// Qobuz API timeout configuration
|
||||
// Mobile networks are more unstable, so we use longer timeouts
|
||||
const (
|
||||
qobuzAPITimeoutMobile = 25 * time.Second
|
||||
qobuzMaxRetries = 2 // Number of retries per API
|
||||
qobuzRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// getQobuzAPITimeout returns appropriate timeout based on platform
|
||||
// For mobile (gomobile builds), we use longer timeouts
|
||||
func getQobuzAPITimeout() time.Duration {
|
||||
// Since this runs in gomobile context, we always use mobile timeout
|
||||
// The Go backend is only used on mobile (Android/iOS)
|
||||
return qobuzAPITimeoutMobile
|
||||
}
|
||||
|
||||
// qobuzSquidCountries defines the region fallback order for squid.wtf API
|
||||
var qobuzSquidCountries = []string{"US", "FR"}
|
||||
|
||||
// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic
|
||||
// For squid.wtf APIs, it tries US region first, then falls back to FR
|
||||
func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) {
|
||||
isSquid := strings.Contains(api, "squid.wtf")
|
||||
|
||||
if isSquid {
|
||||
for _, country := range qobuzSquidCountries {
|
||||
GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country)
|
||||
result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err)
|
||||
}
|
||||
return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)")
|
||||
}
|
||||
|
||||
return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "")
|
||||
}
|
||||
|
||||
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
|
||||
func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) {
|
||||
var lastErr error
|
||||
retryDelay := qobuzRetryDelay
|
||||
|
||||
for attempt := 0; attempt <= qobuzMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2 // Exponential backoff
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(timeout)
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
if country != "" {
|
||||
reqURL += "&country=" + country
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Check for retryable errors (timeout, connection reset)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "eof") {
|
||||
continue // Retry
|
||||
}
|
||||
break // Non-retryable error
|
||||
}
|
||||
// Server errors are retryable
|
||||
if resp.StatusCode >= 500 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
// 429 rate limit - wait and retry
|
||||
if resp.StatusCode == 429 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("rate limited")
|
||||
retryDelay = 2 * time.Second // Wait longer for rate limit
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
return "", fmt.Errorf("received HTML instead of JSON")
|
||||
}
|
||||
|
||||
urlVal, parseErr := extractQobuzDownloadURLFromBody(body)
|
||||
if parseErr == nil {
|
||||
return urlVal, nil
|
||||
}
|
||||
lastErr = parseErr
|
||||
continue
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return "", fmt.Errorf("all retries failed")
|
||||
}
|
||||
|
||||
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
||||
|
||||
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
timeout := getQobuzAPITimeout()
|
||||
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
||||
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout)
|
||||
resultChan <- qobuzAPIResult{
|
||||
apiURL: api,
|
||||
downloadURL: downloadURL,
|
||||
err: err,
|
||||
duration: time.Since(reqStart),
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var errorResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
if result.URL != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
@@ -860,7 +972,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
@@ -897,7 +1009,7 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -916,23 +1028,23 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
@@ -950,13 +1062,17 @@ type QobuzDownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
@@ -964,6 +1080,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
var track *QobuzTrack
|
||||
var err error
|
||||
|
||||
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
|
||||
if req.QobuzID != "" {
|
||||
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
||||
var trackID int64
|
||||
@@ -978,17 +1095,43 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Use cached Qobuz Track ID (fast, no search needed)
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||
track, err = downloader.GetTrackByID(cached.QobuzTrackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||
GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
|
||||
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
|
||||
GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||
songLinkClient := NewSongLinkClient()
|
||||
availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if slErr == nil && availability != nil && availability.QobuzID != "" {
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID)
|
||||
track, err = downloader.GetTrackByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err)
|
||||
track = nil
|
||||
} else if track != nil {
|
||||
GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
|
||||
// Cache for future use
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: ISRC search with duration verification
|
||||
if track == nil && req.ISRC != "" {
|
||||
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||
@@ -1005,7 +1148,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
|
||||
if track == nil {
|
||||
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
@@ -1035,11 +1180,18 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
qobuzQuality := "27"
|
||||
@@ -1077,7 +1229,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
)
|
||||
}()
|
||||
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
@@ -1122,39 +1274,50 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
if isSafOutput {
|
||||
GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
|
||||
return QobuzDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: actualBitDepth,
|
||||
@@ -1166,5 +1329,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||
t.Run("reads nested data.url", func(t *testing.T) {
|
||||
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/audio.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reads top-level url", func(t *testing.T) {
|
||||
body := []byte(`{"url":"https://example.test/top.flac"}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/top.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns API error", func(t *testing.T) {
|
||||
body := []byte(`{"error":"track not found"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "track not found" {
|
||||
t.Fatalf("expected track-not-found error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns message when success false", func(t *testing.T) {
|
||||
body := []byte(`{"success":false,"message":"blocked"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "blocked" {
|
||||
t.Fatalf("expected blocked error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
+121
-35
@@ -8,7 +8,6 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SongLinkClient struct {
|
||||
@@ -26,6 +25,8 @@ type TrackAvailability struct {
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -98,6 +99,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
@@ -111,6 +113,12 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -131,40 +139,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func checkQobuzAvailability(isrc string) bool {
|
||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||
appID := "798273057"
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return false
|
||||
}
|
||||
|
||||
var searchResp struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return searchResp.Tracks.Total > 0
|
||||
}
|
||||
|
||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||
func extractDeezerIDFromURL(deezerURL string) string {
|
||||
parts := strings.Split(deezerURL, "/")
|
||||
@@ -178,6 +152,102 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
||||
// URL formats:
|
||||
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||
// - https://open.qobuz.com/track/12345678
|
||||
// - https://www.qobuz.com/track/12345678
|
||||
// - https://play.qobuz.com/track/12345678
|
||||
func extractQobuzIDFromURL(qobuzURL string) string {
|
||||
if qobuzURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try to find /track/ID pattern first
|
||||
if strings.Contains(qobuzURL, "/track/") {
|
||||
parts := strings.Split(qobuzURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
// Remove trailing slash or path
|
||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
// Validate it's a number
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from album URL with track highlight
|
||||
// Format: /album/albumname/trackid or ?trackId=12345678
|
||||
if strings.Contains(qobuzURL, "trackId=") {
|
||||
parts := strings.Split(qobuzURL, "trackId=")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: get last numeric segment from URL
|
||||
parts := strings.Split(qobuzURL, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(part, "?"); idx > 0 {
|
||||
part = part[:idx]
|
||||
}
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" && isNumeric(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTidalIDFromURL extracts Tidal track ID from URL
|
||||
// URL formats:
|
||||
// - https://tidal.com/browse/track/12345678
|
||||
// - https://listen.tidal.com/track/12345678
|
||||
func extractTidalIDFromURL(tidalURL string) string {
|
||||
if tidalURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.Contains(tidalURL, "/track/") {
|
||||
parts := strings.Split(tidalURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isNumeric is defined in library_scan.go
|
||||
|
||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -353,6 +423,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
@@ -360,6 +431,12 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
}
|
||||
@@ -431,6 +508,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
@@ -438,6 +516,12 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
@@ -552,6 +636,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
@@ -560,6 +645,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
|
||||
+289
-163
@@ -582,12 +582,123 @@ type tidalAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// Tidal API timeout configuration
|
||||
// Mobile networks are more unstable, so we use longer timeouts
|
||||
const (
|
||||
tidalAPITimeoutMobile = 25 * time.Second
|
||||
tidalMaxRetries = 2 // Number of retries per API
|
||||
tidalRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic
|
||||
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
|
||||
var lastErr error
|
||||
retryDelay := tidalRetryDelay
|
||||
|
||||
for attempt := 0; attempt <= tidalMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2 // Exponential backoff
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(timeout)
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Check for retryable errors (timeout, connection reset)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "eof") {
|
||||
continue // Retry
|
||||
}
|
||||
break // Non-retryable error
|
||||
}
|
||||
// Server errors are retryable
|
||||
if resp.StatusCode >= 500 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
// 429 rate limit - wait and retry
|
||||
if resp.StatusCode == 429 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("rate limited")
|
||||
retryDelay = 2 * time.Second // Wait longer for rate limit
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return TidalDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Try V2 response format (with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
return TidalDownloadInfo{}, fmt.Errorf("returned PREVIEW instead of FULL")
|
||||
}
|
||||
|
||||
return TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try V1 response format
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
return TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TidalDownloadInfo{}, fmt.Errorf("no download URL or manifest in response")
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return TidalDownloadInfo{}, lastErr
|
||||
}
|
||||
return TidalDownloadInfo{}, fmt.Errorf("all retries failed")
|
||||
}
|
||||
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
||||
|
||||
resultChan := make(chan tidalAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
@@ -595,69 +706,13 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
||||
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
info, err := fetchTidalURLWithRetry(api, trackID, quality, tidalAPITimeoutMobile)
|
||||
resultChan <- tidalAPIResult{
|
||||
apiURL: api,
|
||||
info: info,
|
||||
err: err,
|
||||
duration: time.Since(reqStart),
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
info := TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
info := TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
@@ -784,6 +839,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||
}
|
||||
|
||||
if segmentCount == 0 {
|
||||
return "", "", nil, fmt.Errorf("no segments found in manifest")
|
||||
}
|
||||
|
||||
for i := 1; i <= segmentCount; i++ {
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
@@ -792,7 +851,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
return "", initURL, mediaURLs, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||
@@ -805,7 +864,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, outputFD, itemID)
|
||||
}
|
||||
|
||||
if itemID != "" {
|
||||
@@ -842,7 +901,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -861,30 +920,30 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
|
||||
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath string, outputFD int, itemID string) error {
|
||||
fmt.Println("[Tidal] Parsing manifest...")
|
||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
@@ -929,7 +988,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
@@ -945,19 +1004,19 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
@@ -965,17 +1024,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
}
|
||||
|
||||
// For DASH format, determine correct M4A path
|
||||
// If outputPath already ends with .m4a, use it directly
|
||||
// Otherwise, convert .flac to .m4a
|
||||
// If outputPath already ends with .m4a, use it directly.
|
||||
// If outputPath ends with .flac, convert .flac to .m4a.
|
||||
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
|
||||
var m4aPath string
|
||||
if strings.HasSuffix(outputPath, ".m4a") {
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
} else if strings.HasSuffix(outputPath, ".flac") {
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
} else {
|
||||
m4aPath = outputPath
|
||||
}
|
||||
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||
|
||||
out, err := os.Create(m4aPath)
|
||||
out, err := openOutputForWrite(m4aPath, outputFD)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
||||
return fmt.Errorf("failed to create M4A file: %w", err)
|
||||
@@ -984,20 +1046,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
GoLog("[Tidal] Downloading init segment...\n")
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Init segment request failed: %v\n", err)
|
||||
return fmt.Errorf("failed to create init segment request: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1007,7 +1069,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
@@ -1015,7 +1077,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1027,7 +1089,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
for i, mediaURL := range mediaURLs {
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
@@ -1043,14 +1105,14 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1060,7 +1122,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||
}
|
||||
@@ -1068,7 +1130,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1078,7 +1140,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Failed to close M4A file: %v\n", err)
|
||||
return fmt.Errorf("failed to close M4A file: %w", err)
|
||||
}
|
||||
@@ -1347,8 +1409,11 @@ func isLatinScript(s string) bool {
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
@@ -1404,49 +1469,83 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
if track == nil && req.SpotifyID != "" {
|
||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
var tidalURL string
|
||||
var slErr error
|
||||
|
||||
var trackID int64
|
||||
var gotTidalID bool
|
||||
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID)
|
||||
availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
if slErr == nil && availability != nil && availability.TidalID != "" {
|
||||
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
// Fallback to URL parsing if TidalID not in struct
|
||||
if !gotTidalID && availability != nil && availability.TidalURL != "" {
|
||||
var idErr error
|
||||
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
||||
if idErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if slErr == nil && availability != nil && availability.TidalID != "" {
|
||||
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
// Fallback to URL parsing if TidalID not in struct
|
||||
if !gotTidalID && availability != nil && availability.TidalURL != "" {
|
||||
var idErr error
|
||||
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
||||
if idErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if slErr == nil && tidalURL != "" {
|
||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||
if idErr == nil {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
if gotTidalID && trackID > 0 {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff > 3 {
|
||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
track = nil // Reject this match
|
||||
}
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff > 3 {
|
||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
track = nil // Reject this match
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for future use
|
||||
if track != nil && req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1513,31 +1612,52 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
var outputPath string
|
||||
var m4aPath string
|
||||
if quality == "HIGH" {
|
||||
filename = sanitizeFilename(filename) + ".m4a"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
outputExt := strings.TrimSpace(req.OutputExt)
|
||||
if outputExt == "" {
|
||||
if quality == "HIGH" {
|
||||
outputExt = ".m4a"
|
||||
} else {
|
||||
outputExt = ".flac"
|
||||
}
|
||||
} else if !strings.HasPrefix(outputExt, ".") {
|
||||
outputExt = "." + outputExt
|
||||
}
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
if quality != "HIGH" {
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
var outputPath string
|
||||
var m4aPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
if outputExt == ".m4a" || quality == "HIGH" {
|
||||
filename = sanitizeFilename(filename) + ".m4a"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
}
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
if quality != "HIGH" {
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
if _, err := os.Stat(tmpPath); err == nil {
|
||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
os.Remove(tmpPath)
|
||||
if !isSafOutput {
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
if _, err := os.Stat(tmpPath); err == nil {
|
||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||
@@ -1572,7 +1692,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return "Direct URL"
|
||||
}())
|
||||
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return TidalDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
@@ -1589,11 +1709,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
|
||||
actualOutputPath := outputPath
|
||||
if _, err := os.Stat(m4aPath); err == nil {
|
||||
actualOutputPath = m4aPath
|
||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||
if !isSafOutput {
|
||||
if _, err := os.Stat(m4aPath); err == nil {
|
||||
actualOutputPath = m4aPath
|
||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||
}
|
||||
}
|
||||
|
||||
releaseDate := req.ReleaseDate
|
||||
@@ -1632,7 +1754,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if strings.HasSuffix(actualOutputPath, ".flac") {
|
||||
actualExt := outputExt
|
||||
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
||||
actualExt = ".m4a"
|
||||
}
|
||||
if actualExt == "" && !isSafOutput {
|
||||
actualExt = strings.ToLower(filepath.Ext(actualOutputPath))
|
||||
}
|
||||
|
||||
if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
@@ -1643,7 +1773,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||
GoLog("[Tidal] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
@@ -1663,7 +1793,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
||||
if quality == "HIGH" {
|
||||
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
||||
|
||||
@@ -1673,7 +1803,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
@@ -1687,7 +1817,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
}
|
||||
|
||||
bitDepth := downloadInfo.BitDepth
|
||||
sampleRate := downloadInfo.SampleRate
|
||||
@@ -1695,15 +1827,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
if quality == "HIGH" {
|
||||
bitDepth = 0
|
||||
sampleRate = 44100
|
||||
if parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return TidalDownloadResult{
|
||||
|
||||
@@ -639,6 +639,14 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "runPostProcessingV2":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let inputJson = args["input"] as? String ?? ""
|
||||
let metadataJson = args["metadata"] as? String ?? ""
|
||||
let response = GobackendRunPostProcessingV2JSON(inputJson, metadataJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getPostProcessingProviders":
|
||||
let response = GobackendGetPostProcessingProvidersJSON(&error)
|
||||
@@ -715,6 +723,14 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "scanLibraryFolderIncremental":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let folderPath = args["folder_path"] as! String
|
||||
let existingFiles = args["existing_files"] as? String ?? "{}"
|
||||
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getLibraryScanProgress":
|
||||
let response = GobackendGetLibraryScanProgressJSON()
|
||||
return response
|
||||
|
||||
+17
-1
@@ -4,15 +4,27 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:spotiflac_android/screens/main_shell.dart';
|
||||
import 'package:spotiflac_android/screens/setup_screen.dart';
|
||||
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
|
||||
final _routerProvider = Provider<GoRouter>((ref) {
|
||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
|
||||
|
||||
// Determine initial location based on app state
|
||||
String initialLocation;
|
||||
if (isFirstLaunch) {
|
||||
initialLocation = '/setup';
|
||||
} else if (!hasCompletedTutorial) {
|
||||
initialLocation = '/tutorial';
|
||||
} else {
|
||||
initialLocation = '/';
|
||||
}
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: isFirstLaunch ? '/setup' : '/',
|
||||
initialLocation: initialLocation,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
@@ -22,6 +34,10 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/setup',
|
||||
builder: (context, state) => const SetupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tutorial',
|
||||
builder: (context, state) => const TutorialScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.4.0';
|
||||
static const String buildNumber = '72';
|
||||
static const String version = '3.5.1';
|
||||
static const String buildNumber = '75';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
@@ -17,4 +17,6 @@ class AppInfo {
|
||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||
static const String bmacUrl = 'https://buymeacoffee.com/zarzet';
|
||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||
}
|
||||
|
||||
@@ -4419,6 +4419,342 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'**
|
||||
String timeHoursAgo(int count);
|
||||
|
||||
/// Dialog title when switching storage mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch Storage Mode'**
|
||||
String get storageSwitchTitle;
|
||||
|
||||
/// Dialog title when switching to SAF
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch to SAF Storage?'**
|
||||
String get storageSwitchToSafTitle;
|
||||
|
||||
/// Dialog title when switching to app storage
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch to App Storage?'**
|
||||
String get storageSwitchToAppTitle;
|
||||
|
||||
/// Explanation when switching to SAF
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'**
|
||||
String get storageSwitchToSafMessage;
|
||||
|
||||
/// Explanation when switching to app storage
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'**
|
||||
String get storageSwitchToAppMessage;
|
||||
|
||||
/// Section header for existing downloads info
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Existing Downloads'**
|
||||
String get storageSwitchExistingDownloads;
|
||||
|
||||
/// Info about existing downloads count
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks in {mode} storage'**
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode);
|
||||
|
||||
/// Section header for new downloads info
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New Downloads'**
|
||||
String get storageSwitchNewDownloads;
|
||||
|
||||
/// Shows where new downloads will go
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Will be saved to: {location}'**
|
||||
String storageSwitchNewDownloadsLocation(String location);
|
||||
|
||||
/// Button to proceed with storage switch
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue'**
|
||||
String get storageSwitchContinue;
|
||||
|
||||
/// Button to select SAF folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select SAF Folder'**
|
||||
String get storageSwitchSelectFolder;
|
||||
|
||||
/// Label for app storage mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'App Storage'**
|
||||
String get storageAppStorage;
|
||||
|
||||
/// Label for SAF storage mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SAF Storage'**
|
||||
String get storageSafStorage;
|
||||
|
||||
/// Badge showing storage mode for a track
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Storage: {mode}'**
|
||||
String storageModeBadge(String mode);
|
||||
|
||||
/// Section title for storage stats
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Storage Statistics'**
|
||||
String get storageStatsTitle;
|
||||
|
||||
/// Count of tracks in app storage
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks in App Storage'**
|
||||
String storageStatsAppCount(int count);
|
||||
|
||||
/// Count of tracks in SAF storage
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks in SAF Storage'**
|
||||
String storageStatsSafCount(int count);
|
||||
|
||||
/// Info when user has files in both storage modes
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your files are stored in multiple locations'**
|
||||
String get storageModeInfo;
|
||||
|
||||
/// Tutorial welcome page title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Welcome to SpotiFLAC!'**
|
||||
String get tutorialWelcomeTitle;
|
||||
|
||||
/// Tutorial welcome page description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'**
|
||||
String get tutorialWelcomeDesc;
|
||||
|
||||
/// Tutorial welcome tip 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download music from Spotify, Deezer, or paste any supported URL'**
|
||||
String get tutorialWelcomeTip1;
|
||||
|
||||
/// Tutorial welcome tip 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'**
|
||||
String get tutorialWelcomeTip2;
|
||||
|
||||
/// Tutorial welcome tip 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Automatic metadata, cover art, and lyrics embedding'**
|
||||
String get tutorialWelcomeTip3;
|
||||
|
||||
/// Tutorial search page title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Finding Music'**
|
||||
String get tutorialSearchTitle;
|
||||
|
||||
/// Tutorial search page description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'There are two easy ways to find music you want to download.'**
|
||||
String get tutorialSearchDesc;
|
||||
|
||||
/// Tutorial search tip 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Paste a Spotify or Deezer URL directly in the search box'**
|
||||
String get tutorialSearchTip1;
|
||||
|
||||
/// Tutorial search tip 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Or type the song name, artist, or album to search'**
|
||||
String get tutorialSearchTip2;
|
||||
|
||||
/// Tutorial search tip 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Supports tracks, albums, playlists, and artist pages'**
|
||||
String get tutorialSearchTip3;
|
||||
|
||||
/// Tutorial download page title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading Music'**
|
||||
String get tutorialDownloadTitle;
|
||||
|
||||
/// Tutorial download page description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading music is simple and fast. Here\'s how it works.'**
|
||||
String get tutorialDownloadDesc;
|
||||
|
||||
/// Tutorial download tip 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap the download button next to any track to start downloading'**
|
||||
String get tutorialDownloadTip1;
|
||||
|
||||
/// Tutorial download tip 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose your preferred quality (FLAC, Hi-Res, or MP3)'**
|
||||
String get tutorialDownloadTip2;
|
||||
|
||||
/// Tutorial download tip 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download entire albums or playlists with one tap'**
|
||||
String get tutorialDownloadTip3;
|
||||
|
||||
/// Tutorial library page title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your Library'**
|
||||
String get tutorialLibraryTitle;
|
||||
|
||||
/// Tutorial library page description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All your downloaded music is organized in the Library tab.'**
|
||||
String get tutorialLibraryDesc;
|
||||
|
||||
/// Tutorial library tip 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'View download progress and queue in the Library tab'**
|
||||
String get tutorialLibraryTip1;
|
||||
|
||||
/// Tutorial library tip 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap any track to play it with your music player'**
|
||||
String get tutorialLibraryTip2;
|
||||
|
||||
/// Tutorial library tip 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch between list and grid view for better browsing'**
|
||||
String get tutorialLibraryTip3;
|
||||
|
||||
/// Tutorial extensions page title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Extensions'**
|
||||
String get tutorialExtensionsTitle;
|
||||
|
||||
/// Tutorial extensions page description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Extend the app\'s capabilities with community extensions.'**
|
||||
String get tutorialExtensionsDesc;
|
||||
|
||||
/// Tutorial extensions tip 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Browse the Store tab to discover useful extensions'**
|
||||
String get tutorialExtensionsTip1;
|
||||
|
||||
/// Tutorial extensions tip 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add new download providers or search sources'**
|
||||
String get tutorialExtensionsTip2;
|
||||
|
||||
/// Tutorial extensions tip 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Get lyrics, enhanced metadata, and more features'**
|
||||
String get tutorialExtensionsTip3;
|
||||
|
||||
/// Tutorial settings page title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Customize Your Experience'**
|
||||
String get tutorialSettingsTitle;
|
||||
|
||||
/// Tutorial settings page description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Personalize the app in Settings to match your preferences.'**
|
||||
String get tutorialSettingsDesc;
|
||||
|
||||
/// Tutorial settings tip 1
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Change download location and folder organization'**
|
||||
String get tutorialSettingsTip1;
|
||||
|
||||
/// Tutorial settings tip 2
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set default audio quality and format preferences'**
|
||||
String get tutorialSettingsTip2;
|
||||
|
||||
/// Tutorial settings tip 3
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Customize app theme and appearance'**
|
||||
String get tutorialSettingsTip3;
|
||||
|
||||
/// Tutorial completion message
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You\'re all set! Start downloading your favorite music now.'**
|
||||
String get tutorialReadyMessage;
|
||||
|
||||
/// Example label in tutorial
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'EXAMPLE'**
|
||||
String get tutorialExample;
|
||||
|
||||
/// Button to force a complete rescan of library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Force Full Scan'**
|
||||
String get libraryForceFullScan;
|
||||
|
||||
/// Subtitle for force full scan button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rescan all files, ignoring cache'**
|
||||
String get libraryForceFullScanSubtitle;
|
||||
|
||||
/// Button to remove history entries for deleted files
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cleanup Orphaned Downloads'**
|
||||
String get cleanupOrphanedDownloads;
|
||||
|
||||
/// Subtitle for orphaned cleanup button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove history entries for files that no longer exist'**
|
||||
String get cleanupOrphanedDownloadsSubtitle;
|
||||
|
||||
/// Snackbar after orphan cleanup
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Removed {count} orphaned entries from history'**
|
||||
String cleanupOrphanedDownloadsResult(int count);
|
||||
|
||||
/// Snackbar when no orphans found
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No orphaned entries found'**
|
||||
String get cleanupOrphanedDownloadsNone;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -2471,4 +2471,211 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
@@ -2456,4 +2456,211 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
@@ -2456,6 +2456,213 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
|
||||
@@ -2456,4 +2456,211 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
@@ -2456,4 +2456,211 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
+2681
-2472
File diff suppressed because it is too large
Load Diff
@@ -2442,4 +2442,211 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
@@ -2456,4 +2456,211 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
@@ -2456,4 +2456,211 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
@@ -2456,6 +2456,213 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
|
||||
@@ -2502,4 +2502,211 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
@@ -2471,4 +2471,211 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
@@ -2456,6 +2456,213 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
|
||||
+154
-1
@@ -1847,5 +1847,158 @@
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"storageSwitchTitle": "Switch Storage Mode",
|
||||
"@storageSwitchTitle": {"description": "Dialog title when switching storage mode"},
|
||||
"storageSwitchToSafTitle": "Switch to SAF Storage?",
|
||||
"@storageSwitchToSafTitle": {"description": "Dialog title when switching to SAF"},
|
||||
"storageSwitchToAppTitle": "Switch to App Storage?",
|
||||
"@storageSwitchToAppTitle": {"description": "Dialog title when switching to app storage"},
|
||||
"storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.",
|
||||
"@storageSwitchToSafMessage": {"description": "Explanation when switching to SAF"},
|
||||
"storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.",
|
||||
"@storageSwitchToAppMessage": {"description": "Explanation when switching to app storage"},
|
||||
"storageSwitchExistingDownloads": "Existing Downloads",
|
||||
"@storageSwitchExistingDownloads": {"description": "Section header for existing downloads info"},
|
||||
"storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage",
|
||||
"@storageSwitchExistingDownloadsInfo": {
|
||||
"description": "Info about existing downloads count",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"mode": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"storageSwitchNewDownloads": "New Downloads",
|
||||
"@storageSwitchNewDownloads": {"description": "Section header for new downloads info"},
|
||||
"storageSwitchNewDownloadsLocation": "Will be saved to: {location}",
|
||||
"@storageSwitchNewDownloadsLocation": {
|
||||
"description": "Shows where new downloads will go",
|
||||
"placeholders": {
|
||||
"location": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"storageSwitchContinue": "Continue",
|
||||
"@storageSwitchContinue": {"description": "Button to proceed with storage switch"},
|
||||
"storageSwitchSelectFolder": "Select SAF Folder",
|
||||
"@storageSwitchSelectFolder": {"description": "Button to select SAF folder"},
|
||||
"storageAppStorage": "App Storage",
|
||||
"@storageAppStorage": {"description": "Label for app storage mode"},
|
||||
"storageSafStorage": "SAF Storage",
|
||||
"@storageSafStorage": {"description": "Label for SAF storage mode"},
|
||||
"storageModeBadge": "Storage: {mode}",
|
||||
"@storageModeBadge": {
|
||||
"description": "Badge showing storage mode for a track",
|
||||
"placeholders": {
|
||||
"mode": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"storageStatsTitle": "Storage Statistics",
|
||||
"@storageStatsTitle": {"description": "Section title for storage stats"},
|
||||
"storageStatsAppCount": "{count} tracks in App Storage",
|
||||
"@storageStatsAppCount": {
|
||||
"description": "Count of tracks in app storage",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"storageStatsSafCount": "{count} tracks in SAF Storage",
|
||||
"@storageStatsSafCount": {
|
||||
"description": "Count of tracks in SAF storage",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"storageModeInfo": "Your files are stored in multiple locations",
|
||||
"@storageModeInfo": {"description": "Info when user has files in both storage modes"},
|
||||
|
||||
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
|
||||
"@tutorialWelcomeTitle": {"description": "Tutorial welcome page title"},
|
||||
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
|
||||
"@tutorialWelcomeDesc": {"description": "Tutorial welcome page description"},
|
||||
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
|
||||
"@tutorialWelcomeTip1": {"description": "Tutorial welcome tip 1"},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"@tutorialWelcomeTip2": {"description": "Tutorial welcome tip 2"},
|
||||
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
|
||||
"@tutorialWelcomeTip3": {"description": "Tutorial welcome tip 3"},
|
||||
|
||||
"tutorialSearchTitle": "Finding Music",
|
||||
"@tutorialSearchTitle": {"description": "Tutorial search page title"},
|
||||
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
|
||||
"@tutorialSearchDesc": {"description": "Tutorial search page description"},
|
||||
"tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box",
|
||||
"@tutorialSearchTip1": {"description": "Tutorial search tip 1"},
|
||||
"tutorialSearchTip2": "Or type the song name, artist, or album to search",
|
||||
"@tutorialSearchTip2": {"description": "Tutorial search tip 2"},
|
||||
"tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages",
|
||||
"@tutorialSearchTip3": {"description": "Tutorial search tip 3"},
|
||||
|
||||
"tutorialDownloadTitle": "Downloading Music",
|
||||
"@tutorialDownloadTitle": {"description": "Tutorial download page title"},
|
||||
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
|
||||
"@tutorialDownloadDesc": {"description": "Tutorial download page description"},
|
||||
"tutorialDownloadTip1": "Tap the download button next to any track to start downloading",
|
||||
"@tutorialDownloadTip1": {"description": "Tutorial download tip 1"},
|
||||
"tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)",
|
||||
"@tutorialDownloadTip2": {"description": "Tutorial download tip 2"},
|
||||
"tutorialDownloadTip3": "Download entire albums or playlists with one tap",
|
||||
"@tutorialDownloadTip3": {"description": "Tutorial download tip 3"},
|
||||
|
||||
"tutorialLibraryTitle": "Your Library",
|
||||
"@tutorialLibraryTitle": {"description": "Tutorial library page title"},
|
||||
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
|
||||
"@tutorialLibraryDesc": {"description": "Tutorial library page description"},
|
||||
"tutorialLibraryTip1": "View download progress and queue in the Library tab",
|
||||
"@tutorialLibraryTip1": {"description": "Tutorial library tip 1"},
|
||||
"tutorialLibraryTip2": "Tap any track to play it with your music player",
|
||||
"@tutorialLibraryTip2": {"description": "Tutorial library tip 2"},
|
||||
"tutorialLibraryTip3": "Switch between list and grid view for better browsing",
|
||||
"@tutorialLibraryTip3": {"description": "Tutorial library tip 3"},
|
||||
|
||||
"tutorialExtensionsTitle": "Extensions",
|
||||
"@tutorialExtensionsTitle": {"description": "Tutorial extensions page title"},
|
||||
"tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.",
|
||||
"@tutorialExtensionsDesc": {"description": "Tutorial extensions page description"},
|
||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
||||
"@tutorialExtensionsTip1": {"description": "Tutorial extensions tip 1"},
|
||||
"tutorialExtensionsTip2": "Add new download providers or search sources",
|
||||
"@tutorialExtensionsTip2": {"description": "Tutorial extensions tip 2"},
|
||||
"tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features",
|
||||
"@tutorialExtensionsTip3": {"description": "Tutorial extensions tip 3"},
|
||||
|
||||
"tutorialSettingsTitle": "Customize Your Experience",
|
||||
"@tutorialSettingsTitle": {"description": "Tutorial settings page title"},
|
||||
"tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.",
|
||||
"@tutorialSettingsDesc": {"description": "Tutorial settings page description"},
|
||||
"tutorialSettingsTip1": "Change download location and folder organization",
|
||||
"@tutorialSettingsTip1": {"description": "Tutorial settings tip 1"},
|
||||
"tutorialSettingsTip2": "Set default audio quality and format preferences",
|
||||
"@tutorialSettingsTip2": {"description": "Tutorial settings tip 2"},
|
||||
"tutorialSettingsTip3": "Customize app theme and appearance",
|
||||
"@tutorialSettingsTip3": {"description": "Tutorial settings tip 3"},
|
||||
|
||||
"tutorialReadyMessage": "You're all set! Start downloading your favorite music now.",
|
||||
"@tutorialReadyMessage": {"description": "Tutorial completion message"},
|
||||
"tutorialExample": "EXAMPLE",
|
||||
"@tutorialExample": {"description": "Example label in tutorial"},
|
||||
|
||||
"libraryForceFullScan": "Force Full Scan",
|
||||
"@libraryForceFullScan": {"description": "Button to force a complete rescan of library"},
|
||||
"libraryForceFullScanSubtitle": "Rescan all files, ignoring cache",
|
||||
"@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"},
|
||||
|
||||
"cleanupOrphanedDownloads": "Cleanup Orphaned Downloads",
|
||||
"@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"},
|
||||
"cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist",
|
||||
"@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"},
|
||||
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
|
||||
"@cleanupOrphanedDownloadsResult": {
|
||||
"description": "Snackbar after orphan cleanup",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
|
||||
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"}
|
||||
}
|
||||
|
||||
+3015
-2861
File diff suppressed because it is too large
Load Diff
+22
-18
@@ -11,21 +11,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await CoverCacheManager.initialize();
|
||||
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
|
||||
|
||||
await Future.wait([
|
||||
NotificationService().initialize(),
|
||||
ShareIntentService().initialize(),
|
||||
]);
|
||||
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: const _EagerInitialization(
|
||||
child: SpotiFLACApp(),
|
||||
),
|
||||
),
|
||||
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,27 +23,43 @@ class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
||||
ConsumerState<_EagerInitialization> createState() =>
|
||||
_EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAppServices();
|
||||
_initializeExtensions();
|
||||
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 {
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
|
||||
await Directory(extensionsDir).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) {
|
||||
debugPrint('Failed to initialize extensions: $e');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ class AppSettings {
|
||||
final String audioQuality;
|
||||
final String filenameFormat;
|
||||
final String downloadDirectory;
|
||||
final String storageMode; // 'app' or 'saf'
|
||||
final String downloadTreeUri; // SAF persistable tree URI
|
||||
final bool autoFallback;
|
||||
final bool embedLyrics;
|
||||
final bool maxQualityCover;
|
||||
@@ -32,7 +34,7 @@ class AppSettings {
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
|
||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||
@@ -41,12 +43,17 @@ class AppSettings {
|
||||
final bool localLibraryEnabled; // Enable local library scanning
|
||||
final String localLibraryPath; // Path to scan for audio files
|
||||
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||
|
||||
// Tutorial/Onboarding
|
||||
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
this.filenameFormat = '{title} - {artist}',
|
||||
this.downloadDirectory = '',
|
||||
this.storageMode = 'app',
|
||||
this.downloadTreeUri = '',
|
||||
this.autoFallback = true,
|
||||
this.embedLyrics = true,
|
||||
this.maxQualityCover = true,
|
||||
@@ -79,6 +86,8 @@ class AppSettings {
|
||||
this.localLibraryEnabled = false,
|
||||
this.localLibraryPath = '',
|
||||
this.localLibraryShowDuplicates = true,
|
||||
// Tutorial default
|
||||
this.hasCompletedTutorial = false,
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -86,6 +95,8 @@ class AppSettings {
|
||||
String? audioQuality,
|
||||
String? filenameFormat,
|
||||
String? downloadDirectory,
|
||||
String? storageMode,
|
||||
String? downloadTreeUri,
|
||||
bool? autoFallback,
|
||||
bool? embedLyrics,
|
||||
bool? maxQualityCover,
|
||||
@@ -119,12 +130,16 @@ class AppSettings {
|
||||
bool? localLibraryEnabled,
|
||||
String? localLibraryPath,
|
||||
bool? localLibraryShowDuplicates,
|
||||
// Tutorial
|
||||
bool? hasCompletedTutorial,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
audioQuality: audioQuality ?? this.audioQuality,
|
||||
filenameFormat: filenameFormat ?? this.filenameFormat,
|
||||
downloadDirectory: downloadDirectory ?? this.downloadDirectory,
|
||||
storageMode: storageMode ?? this.storageMode,
|
||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
@@ -157,6 +172,8 @@ class AppSettings {
|
||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
// Tutorial
|
||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
||||
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
||||
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
||||
storageMode: json['storageMode'] as String? ?? 'app',
|
||||
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
@@ -46,6 +48,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
||||
localLibraryShowDuplicates:
|
||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -54,6 +57,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'audioQuality': instance.audioQuality,
|
||||
'filenameFormat': instance.filenameFormat,
|
||||
'downloadDirectory': instance.downloadDirectory,
|
||||
'storageMode': instance.storageMode,
|
||||
'downloadTreeUri': instance.downloadTreeUri,
|
||||
'autoFallback': instance.autoFallback,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
@@ -85,4 +90,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
@@ -47,6 +49,20 @@ class ExploreItem {
|
||||
durationMs: json['duration_ms'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'uri': uri,
|
||||
'type': type,
|
||||
'name': name,
|
||||
'artists': artists,
|
||||
'description': description,
|
||||
'cover_url': coverUrl,
|
||||
'provider_id': providerId,
|
||||
'album_id': albumId,
|
||||
'album_name': albumName,
|
||||
'duration_ms': durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
class ExploreSection {
|
||||
@@ -75,6 +91,12 @@ class ExploreSection {
|
||||
isYTMusicQuickPicks: isQuickPicks,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'uri': uri,
|
||||
'title': title,
|
||||
'items': items.map((i) => i.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
class ExploreState {
|
||||
@@ -136,20 +158,71 @@ bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
|
||||
}
|
||||
|
||||
class ExploreNotifier extends Notifier<ExploreState> {
|
||||
static const _cacheKey = 'explore_home_feed_cache';
|
||||
static const _cacheTsKey = 'explore_home_feed_ts';
|
||||
|
||||
@override
|
||||
ExploreState build() {
|
||||
_restoreFromCache();
|
||||
return const ExploreState();
|
||||
}
|
||||
|
||||
/// Restore cached home feed from SharedPreferences immediately on startup
|
||||
Future<void> _restoreFromCache() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cached = prefs.getString(_cacheKey);
|
||||
final cachedTs = prefs.getInt(_cacheTsKey);
|
||||
if (cached == null || cached.isEmpty) return;
|
||||
|
||||
final data = jsonDecode(cached) as Map<String, dynamic>;
|
||||
final sectionsData = data['sections'] as List<dynamic>? ?? [];
|
||||
final sections = sectionsData
|
||||
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
if (sections.isEmpty) return;
|
||||
|
||||
final lastFetched = cachedTs != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(cachedTs)
|
||||
: null;
|
||||
|
||||
_log.i('Restored ${sections.length} cached explore sections');
|
||||
state = ExploreState(
|
||||
greeting: _getLocalGreeting(),
|
||||
sections: sections,
|
||||
lastFetched: lastFetched,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to restore explore cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Save home feed to SharedPreferences for instant restore on next launch
|
||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final data = {
|
||||
'sections': sections.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
await prefs.setString(_cacheKey, jsonEncode(data));
|
||||
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
|
||||
_log.d('Saved ${sections.length} explore sections to cache');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save explore cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||
|
||||
// If we have cached content and it's fresh enough, skip network fetch
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
state.lastFetched != null &&
|
||||
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||
_log.d('Using cached home feed');
|
||||
_log.d('Using cached home feed (fresh enough)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -158,7 +231,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
// Only show loading spinner if we have no cached content to display
|
||||
final showLoading = !state.hasContent;
|
||||
state = state.copyWith(isLoading: showLoading, error: null);
|
||||
|
||||
try {
|
||||
final extState = ref.read(extensionProvider);
|
||||
@@ -231,6 +306,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
sections: sections,
|
||||
lastFetched: DateTime.now(),
|
||||
);
|
||||
|
||||
// Save to disk cache for instant restore on next app launch
|
||||
_saveToCache(sections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
state = state.copyWith(
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExtensionProvider');
|
||||
|
||||
const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||
const _providerPriorityKey = 'provider_priority';
|
||||
|
||||
class Extension {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -622,7 +627,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> loadProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getProviderPriority();
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_providerPriorityKey);
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
priority = saved.map((e) => e as String).toList();
|
||||
_log.d('Loaded provider priority from prefs: $priority');
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
} else {
|
||||
// Fallback to Go backend default
|
||||
priority = await PlatformBridge.getProviderPriority();
|
||||
_log.d('Using default provider priority: $priority');
|
||||
}
|
||||
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load provider priority: $e');
|
||||
@@ -632,9 +653,14 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> setProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
_log.d('Updated provider priority: $priority');
|
||||
_log.d('Saved provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
@@ -643,7 +669,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> loadMetadataProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_metadataProviderPriorityKey);
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
priority = saved.map((e) => e as String).toList();
|
||||
_log.d('Loaded metadata provider priority from prefs: $priority');
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
} else {
|
||||
// Fallback to Go backend default
|
||||
priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
_log.d('Using default metadata provider priority: $priority');
|
||||
}
|
||||
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load metadata provider priority: $e');
|
||||
@@ -652,9 +694,14 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
_log.d('Updated metadata provider priority: $priority');
|
||||
_log.d('Saved metadata provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set metadata provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -16,7 +18,9 @@ class LocalLibraryState {
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
final int scannedFiles;
|
||||
final int scanErrorCount;
|
||||
final bool scanWasCancelled;
|
||||
final DateTime? lastScannedAt;
|
||||
final Set<String> _isrcSet;
|
||||
final Set<String> _trackKeySet;
|
||||
@@ -28,18 +32,20 @@ class LocalLibraryState {
|
||||
this.scanProgress = 0,
|
||||
this.scanCurrentFile,
|
||||
this.scanTotalFiles = 0,
|
||||
this.scannedFiles = 0,
|
||||
this.scanErrorCount = 0,
|
||||
this.scanWasCancelled = false,
|
||||
this.lastScannedAt,
|
||||
}) : _isrcSet = items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => item.isrc!)
|
||||
.toSet(),
|
||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc = Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
}) : _isrcSet = items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => item.isrc!)
|
||||
.toSet(),
|
||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc = Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
||||
|
||||
@@ -71,7 +77,9 @@ class LocalLibraryState {
|
||||
double? scanProgress,
|
||||
String? scanCurrentFile,
|
||||
int? scanTotalFiles,
|
||||
int? scannedFiles,
|
||||
int? scanErrorCount,
|
||||
bool? scanWasCancelled,
|
||||
DateTime? lastScannedAt,
|
||||
}) {
|
||||
return LocalLibraryState(
|
||||
@@ -80,7 +88,9 @@ class LocalLibraryState {
|
||||
scanProgress: scanProgress ?? this.scanProgress,
|
||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
|
||||
scannedFiles: scannedFiles ?? this.scannedFiles,
|
||||
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
|
||||
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
|
||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||
);
|
||||
}
|
||||
@@ -88,8 +98,12 @@ class LocalLibraryState {
|
||||
|
||||
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||
Timer? _progressTimer;
|
||||
bool _isLoaded = false;
|
||||
bool _scanCancelRequested = false;
|
||||
int _progressPollingErrorCount = 0;
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
@@ -109,10 +123,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
try {
|
||||
final jsonList = await _db.getAll();
|
||||
final items = jsonList
|
||||
.map((e) => LocalLibraryItem.fromJson(e))
|
||||
.toList();
|
||||
|
||||
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
|
||||
DateTime? lastScannedAt;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -123,9 +135,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
} catch (e) {
|
||||
_log.w('Failed to load lastScannedAt: $e');
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
_log.e('Failed to load library from database: $e', e, stack);
|
||||
}
|
||||
@@ -136,19 +150,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
|
||||
Future<void> startScan(String folderPath) async {
|
||||
Future<void> startScan(
|
||||
String folderPath, {
|
||||
bool forceFullScan = false,
|
||||
}) async {
|
||||
if (state.isScanning) {
|
||||
_log.w('Scan already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
_log.i('Starting library scan: $folderPath');
|
||||
_scanCancelRequested = false;
|
||||
_log.i(
|
||||
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
|
||||
);
|
||||
state = state.copyWith(
|
||||
isScanning: true,
|
||||
scanProgress: 0,
|
||||
scanCurrentFile: null,
|
||||
scanTotalFiles: 0,
|
||||
scannedFiles: 0,
|
||||
scanErrorCount: 0,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -163,36 +185,186 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_startProgressPolling();
|
||||
|
||||
try {
|
||||
final results = await PlatformBridge.scanLibraryFolder(folderPath);
|
||||
|
||||
final items = <LocalLibraryItem>[];
|
||||
for (final json in results) {
|
||||
final item = LocalLibraryItem.fromJson(json);
|
||||
items.add(item);
|
||||
}
|
||||
final isSaf = folderPath.startsWith('content://');
|
||||
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
// Get all file paths from download history to exclude them
|
||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||
_log.i(
|
||||
'Excluding ${downloadedPaths.length} downloaded files from library scan',
|
||||
);
|
||||
|
||||
_log.i('Scan complete: ${items.length} tracks found');
|
||||
if (forceFullScan) {
|
||||
// Full scan path - ignores existing data
|
||||
final results = isSaf
|
||||
? await PlatformBridge.scanSafTree(folderPath)
|
||||
: await PlatformBridge.scanLibraryFolder(folderPath);
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final items = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
for (final json in results) {
|
||||
final filePath = json['filePath'] as String?;
|
||||
// Skip files that are already in download history
|
||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
final item = LocalLibraryItem.fromJson(json);
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
if (skippedDownloads > 0) {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
|
||||
_log.i('Full scan complete: ${items.length} tracks found');
|
||||
} else {
|
||||
// Incremental scan path - only scans new/modified files
|
||||
final existingFiles = await _db.getFileModTimes();
|
||||
_log.i(
|
||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||
);
|
||||
|
||||
final backfilledModTimes = await _backfillLegacyFileModTimes(
|
||||
isSaf: isSaf,
|
||||
existingFiles: existingFiles,
|
||||
);
|
||||
if (backfilledModTimes.isNotEmpty) {
|
||||
await _db.updateFileModTimes(backfilledModTimes);
|
||||
existingFiles.addAll(backfilledModTimes);
|
||||
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
|
||||
}
|
||||
|
||||
// Use appropriate incremental scan method based on SAF or not
|
||||
final Map<String, dynamic> result;
|
||||
if (isSaf) {
|
||||
result = await PlatformBridge.scanSafTreeIncremental(
|
||||
folderPath,
|
||||
existingFiles,
|
||||
);
|
||||
} else {
|
||||
result = await PlatformBridge.scanLibraryFolderIncremental(
|
||||
folderPath,
|
||||
existingFiles,
|
||||
);
|
||||
}
|
||||
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse incremental scan result
|
||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
||||
final scannedList =
|
||||
(result['files'] as List<dynamic>?) ??
|
||||
(result['scanned'] as List<dynamic>?) ??
|
||||
[];
|
||||
final deletedPaths =
|
||||
(result['removedUris'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
(result['deletedPaths'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[];
|
||||
final skippedCount = result['skippedCount'] as int? ?? 0;
|
||||
final totalFiles = result['totalFiles'] as int? ?? 0;
|
||||
|
||||
_log.i(
|
||||
'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)
|
||||
final updatedItems = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
if (scannedList.isNotEmpty) {
|
||||
for (final json in scannedList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
final filePath = map['filePath'] as String?;
|
||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
final item = LocalLibraryItem.fromJson(map);
|
||||
updatedItems.add(item);
|
||||
currentByPath[item.filePath] = item;
|
||||
}
|
||||
if (updatedItems.isNotEmpty) {
|
||||
await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList());
|
||||
_log.i('Upserted ${updatedItems.length} items');
|
||||
}
|
||||
if (skippedDownloads > 0) {
|
||||
_log.i(
|
||||
'Skipped $skippedDownloads files already in download history',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removed items
|
||||
if (deletedPaths.isNotEmpty) {
|
||||
final deleteCount = await _db.deleteByPaths(deletedPaths);
|
||||
for (final path in deletedPaths) {
|
||||
currentByPath.remove(path);
|
||||
}
|
||||
_log.i('Deleted $deleteCount items from database');
|
||||
}
|
||||
|
||||
final items = currentByPath.values.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
|
||||
_log.i(
|
||||
'Incremental scan complete: ${items.length} total tracks '
|
||||
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_log.e('Library scan failed: $e', e, stack);
|
||||
state = state.copyWith(isScanning: false);
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
||||
} finally {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
@@ -200,35 +372,44 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
|
||||
|
||||
state = state.copyWith(
|
||||
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
|
||||
scanCurrentFile: progress['current_file'] as String?,
|
||||
scanTotalFiles: progress['total_files'] as int? ?? 0,
|
||||
scannedFiles: progress['scanned_files'] as int? ?? 0,
|
||||
scanErrorCount: progress['error_count'] as int? ?? 0,
|
||||
);
|
||||
|
||||
if (progress['is_complete'] == true) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
} catch (_) {}
|
||||
_progressPollingErrorCount = 0;
|
||||
} catch (e) {
|
||||
_progressPollingErrorCount++;
|
||||
if (_progressPollingErrorCount <= 3) {
|
||||
_log.w('Library scan progress polling failed: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_progressPollingErrorCount = 0;
|
||||
}
|
||||
|
||||
Future<void> cancelScan() async {
|
||||
if (!state.isScanning) return;
|
||||
|
||||
|
||||
_log.i('Cancelling library scan');
|
||||
_scanCancelRequested = true;
|
||||
await PlatformBridge.cancelLibraryScan();
|
||||
state = state.copyWith(isScanning: false);
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
@@ -242,14 +423,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
Future<void> clearLibrary() async {
|
||||
await _db.clearAll();
|
||||
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_lastScannedAtKey);
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear lastScannedAt: $e');
|
||||
}
|
||||
|
||||
|
||||
state = LocalLibraryState();
|
||||
_log.i('Library cleared');
|
||||
}
|
||||
@@ -273,7 +454,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
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) {
|
||||
final byIsrc = state.getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
@@ -286,7 +471,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
Future<List<LocalLibraryItem>> search(String query) async {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
|
||||
final results = await _db.search(query);
|
||||
return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
}
|
||||
@@ -294,6 +479,78 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
Future<int> getCount() async {
|
||||
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({
|
||||
required bool isSaf,
|
||||
required Map<String, int> existingFiles,
|
||||
}) async {
|
||||
final legacyPaths = existingFiles.entries
|
||||
.where((entry) => entry.value <= 0)
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
if (legacyPaths.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
if (isSaf) {
|
||||
final uris = legacyPaths
|
||||
.where((path) => path.startsWith('content://'))
|
||||
.toList();
|
||||
if (uris.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
const chunkSize = 500;
|
||||
final backfilled = <String, int>{};
|
||||
try {
|
||||
for (var i = 0; i < uris.length; i += chunkSize) {
|
||||
if (_scanCancelRequested) {
|
||||
break;
|
||||
}
|
||||
final end = (i + chunkSize < uris.length)
|
||||
? i + chunkSize
|
||||
: uris.length;
|
||||
final chunk = uris.sublist(i, end);
|
||||
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
|
||||
backfilled.addAll(chunkResult);
|
||||
}
|
||||
return backfilled;
|
||||
} catch (e) {
|
||||
_log.w('Failed to backfill SAF mod times: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
final backfilled = <String, int>{};
|
||||
for (final path in legacyPaths) {
|
||||
if (_scanCancelRequested || path.startsWith('content://')) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type == FileSystemEntityType.file) {
|
||||
backfilled[path] = stat.modified.millisecondsSinceEpoch;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return backfilled;
|
||||
}
|
||||
}
|
||||
|
||||
final localLibraryProvider =
|
||||
|
||||
@@ -8,12 +8,16 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 1;
|
||||
const _currentMigrationVersion = 2;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
bool _isSavingSettings = false;
|
||||
bool _saveQueued = false;
|
||||
String? _pendingSettingsJson;
|
||||
|
||||
@override
|
||||
AppSettings build() {
|
||||
@@ -26,47 +30,78 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final json = prefs.getString(_settingsKey);
|
||||
if (json != null) {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
|
||||
await _runMigrations(prefs);
|
||||
}
|
||||
|
||||
await _loadSpotifyClientSecret(prefs);
|
||||
|
||||
_applySpotifyCredentials();
|
||||
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||
|
||||
|
||||
if (lastMigration < 1) {
|
||||
if (!state.useCustomSpotifyCredentials) {
|
||||
state = state.copyWith(metadataSource: 'deezer');
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (lastMigration < _currentMigrationVersion) {
|
||||
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
|
||||
state = state.copyWith(storageMode: 'saf');
|
||||
}
|
||||
// Migration 2: existing users who already completed setup should skip tutorial
|
||||
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
}
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await _prefs;
|
||||
final settingsToSave = state.copyWith(
|
||||
spotifyClientSecret: '',
|
||||
);
|
||||
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson()));
|
||||
final settingsToSave = state.copyWith(spotifyClientSecret: '');
|
||||
_pendingSettingsJson = jsonEncode(settingsToSave.toJson());
|
||||
|
||||
if (_isSavingSettings) {
|
||||
_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 {
|
||||
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey);
|
||||
final storedSecret = await _secureStorage.read(
|
||||
key: _spotifyClientSecretKey,
|
||||
);
|
||||
final prefsSecret = state.spotifyClientSecret;
|
||||
|
||||
if ((storedSecret == null || storedSecret.isEmpty) &&
|
||||
prefsSecret.isNotEmpty) {
|
||||
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret);
|
||||
await _secureStorage.write(
|
||||
key: _spotifyClientSecretKey,
|
||||
value: prefsSecret,
|
||||
);
|
||||
}
|
||||
|
||||
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
|
||||
@@ -91,7 +126,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
await PlatformBridge.setSpotifyCredentials(
|
||||
state.spotifyClientId,
|
||||
@@ -120,6 +155,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setStorageMode(String mode) {
|
||||
final normalized = mode == 'saf' ? 'saf' : 'app';
|
||||
state = state.copyWith(storageMode: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDownloadTreeUri(String uri, {String? displayName}) {
|
||||
final nextDisplay = displayName ?? state.downloadDirectory;
|
||||
state = state.copyWith(
|
||||
downloadTreeUri: uri,
|
||||
storageMode: uri.isNotEmpty ? 'saf' : state.storageMode,
|
||||
downloadDirectory: nextDisplay,
|
||||
);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoFallback(bool enabled) {
|
||||
state = state.copyWith(autoFallback: enabled);
|
||||
_saveSettings();
|
||||
@@ -148,7 +199,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setConcurrentDownloads(int count) {
|
||||
final clamped = count.clamp(1, 3);
|
||||
final clamped = count.clamp(1, 5);
|
||||
state = state.copyWith(concurrentDownloads: clamped);
|
||||
_saveSettings();
|
||||
}
|
||||
@@ -201,7 +252,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||
Future<void> setSpotifyCredentials(
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: clientId,
|
||||
spotifyClientSecret: clientSecret,
|
||||
@@ -212,10 +266,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
Future<void> clearSpotifyCredentials() async {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: '',
|
||||
spotifyClientSecret: '',
|
||||
);
|
||||
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
|
||||
await _storeSpotifyClientSecret('');
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
@@ -277,7 +328,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(useAllFilesAccess: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
@@ -306,6 +357,11 @@ void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(localLibraryShowDuplicates: show);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setTutorialComplete() {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
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/palette_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
@@ -12,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
|
||||
@@ -69,7 +69,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
String? _artistId;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
@@ -103,8 +102,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
if (_tracks == null || _tracks!.isEmpty) {
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
_extractDominantColor();
|
||||
}
|
||||
|
||||
@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) {
|
||||
if (date.length >= 10) {
|
||||
final parts = date.substring(0, 10).split('-');
|
||||
@@ -232,7 +221,6 @@ Future<void> _fetchTracks() async {
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5;
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
@@ -264,18 +252,32 @@ Future<void> _fetchTracks() async {
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
// Blurred cover background
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
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],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -695,8 +697,8 @@ child: ListTile(
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -14,6 +13,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
@@ -1061,8 +1061,8 @@ if (hasValidImage)
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.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/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
|
||||
@@ -31,7 +29,6 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@@ -39,7 +36,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_extractDominantColor();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -56,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)
|
||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||
return allItems.where((item) {
|
||||
@@ -180,10 +153,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
if (item != null) {
|
||||
try {
|
||||
final file = File(item.filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
await deleteFile(item.filePath);
|
||||
} catch (_) {}
|
||||
historyNotifier.removeFromHistory(id);
|
||||
deletedCount++;
|
||||
@@ -202,8 +172,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
final mimeType = audioMimeTypeForPath(filePath);
|
||||
await OpenFilex.open(filePath, type: mimeType);
|
||||
await openFile(filePath);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -300,7 +269,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
@@ -332,19 +300,32 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
// Blurred cover background
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
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,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -49,6 +50,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
String? _lastSearchQuery;
|
||||
late final ProviderSubscription<TrackState> _trackStateSub;
|
||||
late final ProviderSubscription<bool> _extensionInitSub;
|
||||
late final ProviderSubscription<bool> _homeFeedExtSub;
|
||||
|
||||
Timer? _liveSearchDebounce;
|
||||
bool _isLiveSearchInProgress = false;
|
||||
@@ -87,6 +89,20 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Watch for new homeFeed extension being installed/enabled after init
|
||||
_homeFeedExtSub = ref.listenManual<bool>(
|
||||
extensionProvider.select((s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed)),
|
||||
(previous, next) {
|
||||
if (next == true && previous != true) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
ref.read(exploreProvider.notifier).fetchHomeFeed(forceRefresh: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _fetchExploreIfNeeded() {
|
||||
@@ -105,6 +121,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
_liveSearchDebounce?.cancel();
|
||||
_trackStateSub.close();
|
||||
_extensionInitSub.close();
|
||||
_homeFeedExtSub.close();
|
||||
_urlController.removeListener(_onSearchChanged);
|
||||
_searchFocusNode.removeListener(_onSearchFocusChanged);
|
||||
_urlController.dispose();
|
||||
@@ -494,7 +511,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
: null;
|
||||
|
||||
final hasExploreContent = exploreSections.isNotEmpty;
|
||||
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && hasExploreContent;
|
||||
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && (hasHomeFeedExtension || hasExploreContent) && hasExploreContent;
|
||||
|
||||
// Get current search extension and its filters
|
||||
final settings = ref.watch(settingsProvider);
|
||||
@@ -2569,8 +2586,8 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.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';
|
||||
|
||||
/// Screen to display tracks from a local library album
|
||||
@@ -31,7 +30,6 @@ class LocalAlbumScreen extends ConsumerStatefulWidget {
|
||||
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late List<LocalLibraryItem> _sortedTracksCache;
|
||||
@@ -44,13 +42,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_rebuildTrackCaches();
|
||||
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
|
||||
if (cachedColor != null) {
|
||||
_dominantColor = cachedColor;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_extractDominantColor();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -60,13 +51,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
oldWidget.tracks.length != widget.tracks.length) {
|
||||
_rebuildTrackCaches();
|
||||
}
|
||||
if (oldWidget.coverPath != widget.coverPath) {
|
||||
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
|
||||
if (cachedColor != null && cachedColor != _dominantColor) {
|
||||
_dominantColor = cachedColor;
|
||||
}
|
||||
_extractDominantColor();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -83,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() {
|
||||
final tracks = List<LocalLibraryItem>.from(widget.tracks);
|
||||
tracks.sort((a, b) {
|
||||
@@ -192,10 +164,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
if (item != null) {
|
||||
try {
|
||||
final file = File(item.filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
await deleteFile(item.filePath);
|
||||
} catch (_) {}
|
||||
libraryNotifier.removeItem(id);
|
||||
deletedCount++;
|
||||
@@ -219,8 +188,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
final mimeType = audioMimeTypeForPath(filePath);
|
||||
await OpenFilex.open(filePath, type: mimeType);
|
||||
await openFile(filePath);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -294,7 +262,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5;
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
@@ -326,19 +293,30 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
// Blurred cover background
|
||||
if (widget.coverPath != null)
|
||||
Image.file(
|
||||
File(widget.coverPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
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],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+107
-7
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
@@ -12,6 +15,7 @@ import 'package:spotiflac_android/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/screens/store_tab.dart';
|
||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
@@ -40,6 +44,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForUpdates();
|
||||
_setupShareListener();
|
||||
_checkSafMigration();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,9 +82,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
|
||||
if (_currentIndex != 0) {
|
||||
@@ -115,6 +120,92 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
}
|
||||
|
||||
static const _safMigrationShownKey = 'saf_migration_prompt_shown';
|
||||
|
||||
Future<void> _checkSafMigration() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
// Only show if user is still on legacy storage mode with a download dir set
|
||||
if (settings.storageMode == 'saf') return;
|
||||
if (settings.downloadDirectory.isEmpty) return;
|
||||
|
||||
// Check Android version
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
if (androidInfo.version.sdkInt < 29) return;
|
||||
|
||||
// Only show once
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getBool(_safMigrationShownKey) == true) return;
|
||||
await prefs.setBool(_safMigrationShownKey, true);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
icon: Icon(
|
||||
Icons.folder_special_outlined,
|
||||
size: 32,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
title: const Text('Storage Update Required'),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. '
|
||||
'This fixes "permission denied" errors on Android 10+.',
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Please select your download folder again to switch to the new storage system.',
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await PlatformBridge.pickSafTree();
|
||||
if (result != null) {
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
final displayName = result['display_name'] as String? ?? '';
|
||||
if (treeUri.isNotEmpty) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadTreeUri(
|
||||
treeUri,
|
||||
displayName: displayName.isNotEmpty
|
||||
? displayName
|
||||
: treeUri,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download folder updated to SAF mode'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Select Folder'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shareSubscription?.cancel();
|
||||
@@ -193,7 +284,16 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final queueState = ref.watch(
|
||||
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(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
@@ -205,10 +305,10 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
final canPop =
|
||||
_currentIndex == 0 &&
|
||||
!trackState.hasSearchText &&
|
||||
!trackState.hasContent &&
|
||||
!trackState.isLoading &&
|
||||
!trackState.isShowingRecentAccess &&
|
||||
!trackHasSearchText &&
|
||||
!trackHasContent &&
|
||||
!trackIsLoading &&
|
||||
!trackIsShowingRecentAccess &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
final tabs = <Widget>[
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
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/palette_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
@@ -32,7 +32,6 @@ class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
List<Track>? _fetchedTracks;
|
||||
@@ -45,7 +44,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_extractDominantColor();
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -151,7 +141,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
@@ -183,19 +172,32 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
// Blurred cover background
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
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],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -512,8 +514,8 @@ leading: track.coverUrl != null
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+599
-540
File diff suppressed because it is too large
Load Diff
@@ -191,23 +191,6 @@ _AboutSettingsItem(
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.coffee_outlined,
|
||||
title: context.l10n.aboutBuyMeCoffee,
|
||||
subtitle: context.l10n.aboutBuyMeCoffeeSubtitle,
|
||||
onTap: () => _launchUrl(AppInfo.kofiUrl),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutApp),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/widgets/donate_icons.dart';
|
||||
|
||||
class DonatePage extends StatelessWidget {
|
||||
const DonatePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Donate',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header message
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.favorite_rounded,
|
||||
size: 48,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Support SpotiFLAC-Mobile',
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'SpotiFLAC-Mobile is free and open source. '
|
||||
'If you enjoy using it, consider supporting '
|
||||
'the development.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Donate links card
|
||||
_DonateLinksCard(colorScheme: colorScheme),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Recent donors section
|
||||
_RecentDonorsCard(colorScheme: colorScheme),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Notice
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 20,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'About Supporters',
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'By supporting SpotiFLAC, you become part of this app\'s history. '
|
||||
'Your name will remain in this version permanently as a token of appreciation. '
|
||||
'The supporter list is updated manually each month and embedded directly in the app '
|
||||
'-- no remote server is used. Even if your support period ends, your name stays in '
|
||||
'every version it was included in.',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentDonorsCard extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _RecentDonorsCard({required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Match SettingsGroup color logic
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
colorScheme.surface,
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.star_rounded, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Recent Supporters',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Thank you for your generosity!',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
||||
_DonorTile(
|
||||
name: '283Fabio',
|
||||
colorScheme: colorScheme,
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DonateLinksCard extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _DonateLinksCard({required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
colorScheme.surface,
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_DonateCardItem(
|
||||
title: 'Ko-fi',
|
||||
subtitle: 'ko-fi.com/zarzet',
|
||||
customIcon: const KofiIcon(size: 22, color: Colors.white),
|
||||
color: const Color(0xFFFF5E5B),
|
||||
url: AppInfo.kofiUrl,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 74,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
_DonateCardItem(
|
||||
title: 'Buy Me a Coffee',
|
||||
subtitle: 'buymeacoffee.com/zarzet',
|
||||
customIcon: const BmacIcon(size: 22, color: Colors.black87),
|
||||
color: const Color(0xFFFFDD00),
|
||||
url: AppInfo.bmacUrl,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 74,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
_DonateCardItem(
|
||||
title: 'GitHub Sponsors',
|
||||
subtitle: 'github.com/sponsors/zarzet',
|
||||
customIcon: const GitHubIcon(size: 22, color: Colors.white),
|
||||
color: const Color(0xFF2D333B),
|
||||
url: AppInfo.githubSponsorsUrl,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DonateCardItem extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget customIcon;
|
||||
final Color color;
|
||||
final String url;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _DonateCardItem({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.customIcon,
|
||||
required this.color,
|
||||
required this.url,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () =>
|
||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(child: customIcon),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.open_in_new,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DonorTile extends StatelessWidget {
|
||||
final String name;
|
||||
final ColorScheme colorScheme;
|
||||
final bool showDivider;
|
||||
|
||||
const _DonorTile({
|
||||
required this.name,
|
||||
required this.colorScheme,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,15 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
const DownloadSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DownloadSettingsPage> createState() => _DownloadSettingsPageState();
|
||||
ConsumerState<DownloadSettingsPage> createState() =>
|
||||
_DownloadSettingsPageState();
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
@@ -92,7 +94,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
final isTidalService = settings.defaultService == 'tidal';
|
||||
|
||||
@@ -102,43 +104,43 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.downloadTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
title: Text(
|
||||
context.l10n.downloadTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionService),
|
||||
@@ -157,7 +159,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionAudioQuality,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -165,7 +169,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.downloadAskBeforeDownload,
|
||||
subtitle: isBuiltInService
|
||||
subtitle: isBuiltInService
|
||||
? context.l10n.downloadAskQualitySubtitle
|
||||
: 'Select a built-in service to enable',
|
||||
value: settings.askQualityBeforeDownload,
|
||||
@@ -174,7 +178,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
.read(settingsProvider.notifier)
|
||||
.setAskQualityBeforeDownload(value),
|
||||
),
|
||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||
if (!settings.askQualityBeforeDownload &&
|
||||
isBuiltInService) ...[
|
||||
_QualityOption(
|
||||
title: context.l10n.qualityFlacLossless,
|
||||
subtitle: context.l10n.qualityFlacLosslessSubtitle,
|
||||
@@ -204,7 +209,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
if (isTidalService)
|
||||
_QualityOption(
|
||||
title: 'Lossy 320kbps',
|
||||
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
isSelected: settings.audioQuality == 'HIGH',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -215,8 +222,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SettingsItem(
|
||||
icon: Icons.tune,
|
||||
title: 'Lossy Format',
|
||||
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||
onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat),
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
onTap: () => _showTidalHighFormatPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -234,43 +247,46 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Select Tidal, Qobuz, or Amazon above to configure quality',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionFileSettings,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
@@ -309,7 +325,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: context.l10n.downloadAlbumFolderStructure,
|
||||
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
|
||||
subtitle: _getAlbumFolderStructureLabel(
|
||||
settings.albumFolderStructure,
|
||||
),
|
||||
onTap: () => _showAlbumFolderStructurePicker(
|
||||
context,
|
||||
ref,
|
||||
@@ -347,7 +365,11 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
subtitle: settings.downloadNetworkMode == 'wifi_only'
|
||||
? context.l10n.settingsDownloadNetworkWifiOnly
|
||||
: context.l10n.settingsDownloadNetworkAny,
|
||||
onTap: () => _showNetworkModePicker(context, ref, settings.downloadNetworkMode),
|
||||
onTap: () => _showNetworkModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.downloadNetworkMode,
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.file_download_outlined,
|
||||
@@ -355,7 +377,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
|
||||
value: settings.autoExportFailedDownloads,
|
||||
onChanged: (value) {
|
||||
ref.read(settingsProvider.notifier).setAutoExportFailedDownloads(value);
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAutoExportFailedDownloads(value);
|
||||
},
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -366,7 +390,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
// All Files Access section (Android 13+ only)
|
||||
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionStorageAccess),
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionStorageAccess,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -405,16 +431,15 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.allFilesAccessDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
@@ -438,7 +463,11 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
|
||||
void _showAlbumFolderStructurePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
@@ -449,9 +478,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(context.l10n.albumFolderArtistAlbum),
|
||||
subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle),
|
||||
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
|
||||
trailing: current == 'artist_album'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -459,9 +492,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
leading: const Icon(Icons.calendar_today_outlined),
|
||||
title: Text(context.l10n.albumFolderArtistYearAlbum),
|
||||
subtitle: Text(context.l10n.albumFolderArtistYearAlbumSubtitle),
|
||||
trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null,
|
||||
trailing: current == 'artist_year_album'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('artist_year_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -469,9 +506,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
leading: const Icon(Icons.album_outlined),
|
||||
title: Text(context.l10n.albumFolderAlbumOnly),
|
||||
subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle),
|
||||
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
|
||||
trailing: current == 'album_only'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('album_only');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -479,19 +520,29 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
leading: const Icon(Icons.event_outlined),
|
||||
title: Text(context.l10n.albumFolderYearAlbum),
|
||||
subtitle: Text(context.l10n.albumFolderYearAlbumSubtitle),
|
||||
trailing: current == 'year_album' ? const Icon(Icons.check) : null,
|
||||
trailing: current == 'year_album'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('year_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outlined),
|
||||
title: Text(context.l10n.albumFolderArtistAlbumSingles),
|
||||
subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle),
|
||||
trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null,
|
||||
subtitle: Text(
|
||||
context.l10n.albumFolderArtistAlbumSinglesSubtitle,
|
||||
),
|
||||
trailing: current == 'artist_album_singles'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('artist_album_singles');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -634,7 +685,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
@@ -680,13 +731,123 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
if (Platform.isIOS) {
|
||||
_showIOSDirectoryOptions(context, ref);
|
||||
} else {
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
_showAndroidDirectoryOptions(context, ref);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _getDefaultAndroidDirectory() async {
|
||||
final directMusicPath = '/storage/emulated/0/Music/SpotiFLAC';
|
||||
try {
|
||||
final musicDir = Directory(directMusicPath);
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
}
|
||||
return musicDir.path;
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final externalDir = await getExternalStorageDirectory();
|
||||
if (externalDir != null) {
|
||||
final musicDir = Directory(
|
||||
'${externalDir.parent.parent.parent.parent.path}/Music/SpotiFLAC',
|
||||
);
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
}
|
||||
return musicDir.path;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final fallbackDir = Directory('${appDir.path}/SpotiFLAC');
|
||||
if (!await fallbackDir.exists()) {
|
||||
await fallbackDir.create(recursive: true);
|
||||
}
|
||||
return fallbackDir.path;
|
||||
}
|
||||
|
||||
void _showAndroidDirectoryOptions(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final isSafMode =
|
||||
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Download Location',
|
||||
style: Theme.of(
|
||||
ctx,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Choose storage mode for downloaded files.',
|
||||
style: Theme.of(ctx).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||
title: const Text('App folder (non-SAF)'),
|
||||
subtitle: const Text('Use default Music/SpotiFLAC path'),
|
||||
trailing: !isSafMode ? const Icon(Icons.check) : null,
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final defaultDir = await _getDefaultAndroidDirectory();
|
||||
final notifier = ref.read(settingsProvider.notifier);
|
||||
notifier.setStorageMode('app');
|
||||
notifier.setDownloadDirectory(defaultDir);
|
||||
notifier.setDownloadTreeUri('');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder_open, color: colorScheme.primary),
|
||||
title: const Text('SAF folder'),
|
||||
subtitle: const Text(
|
||||
'Pick folder via Android Storage Access Framework',
|
||||
),
|
||||
trailing: isSafMode ? const Icon(Icons.check) : null,
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await PlatformBridge.pickSafTree();
|
||||
if (result != null) {
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
final displayName = result['display_name'] as String? ?? '';
|
||||
if (treeUri.isNotEmpty) {
|
||||
ref.read(settingsProvider.notifier).setStorageMode('saf');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadTreeUri(
|
||||
treeUri,
|
||||
displayName: displayName.isNotEmpty
|
||||
? displayName
|
||||
: treeUri,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
@@ -731,7 +892,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
ListTile(
|
||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||
title: Text(context.l10n.setupChooseFromFiles),
|
||||
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||
@@ -742,7 +903,8 @@ ListTile(
|
||||
if (result != null) {
|
||||
// iOS: Check if user selected iCloud Drive (not accessible by Go backend)
|
||||
if (Platform.isIOS) {
|
||||
final isICloudPath = result.contains('Mobile Documents') ||
|
||||
final isICloudPath =
|
||||
result.contains('Mobile Documents') ||
|
||||
result.contains('CloudDocs') ||
|
||||
result.contains('com~apple~CloudDocs');
|
||||
if (isICloudPath) {
|
||||
@@ -899,6 +1061,8 @@ ListTile(
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
return 'MP3 320kbps';
|
||||
case 'opus_256':
|
||||
return 'Opus 256kbps';
|
||||
case 'opus_128':
|
||||
return 'Opus 128kbps';
|
||||
default:
|
||||
@@ -945,19 +1109,41 @@ ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: const Text('MP3 320kbps'),
|
||||
subtitle: const Text('Best compatibility, ~10MB per track'),
|
||||
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
trailing: current == 'mp3_320'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('mp3_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: const Text('Opus 256kbps'),
|
||||
subtitle: const Text('Best quality Opus, ~8MB per track'),
|
||||
trailing: current == 'opus_256'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_256');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: const Text('Opus 128kbps'),
|
||||
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
subtitle: const Text('Smallest size, ~4MB per track'),
|
||||
trailing: current == 'opus_128'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_128');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1007,9 +1193,13 @@ ListTile(
|
||||
leading: const Icon(Icons.signal_cellular_alt),
|
||||
title: Text(context.l10n.settingsDownloadNetworkAny),
|
||||
subtitle: const Text('WiFi + Mobile Data'),
|
||||
trailing: current == 'any' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
trailing: current == 'any'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setDownloadNetworkMode('any');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadNetworkMode('any');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1017,9 +1207,13 @@ ListTile(
|
||||
leading: const Icon(Icons.wifi),
|
||||
title: Text(context.l10n.settingsDownloadNetworkWifiOnly),
|
||||
subtitle: const Text('Pause downloads on mobile data'),
|
||||
trailing: current == 'wifi_only' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
trailing: current == 'wifi_only'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setDownloadNetworkMode('wifi_only');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadNetworkMode('wifi_only');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1076,7 +1270,9 @@ ListTile(
|
||||
example: 'SpotiFLAC/Track.flac',
|
||||
isSelected: current == 'none',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('none');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('none');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1086,7 +1282,9 @@ ListTile(
|
||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||
isSelected: current == 'artist',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('artist');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1096,7 +1294,9 @@ ListTile(
|
||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||
isSelected: current == 'album',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1106,7 +1306,9 @@ ListTile(
|
||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||
isSelected: current == 'artist_album',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1130,18 +1332,22 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
|
||||
final isExtensionService = ![
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'amazon',
|
||||
].contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
: true;
|
||||
|
||||
|
||||
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
|
||||
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
@@ -1167,6 +1373,7 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
isDisabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
@@ -1241,8 +1448,8 @@ class _ServiceChip extends StatelessWidget {
|
||||
color: isDisabled
|
||||
? disabledColor
|
||||
: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: unselectedColor,
|
||||
? colorScheme.primaryContainer
|
||||
: unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: isDisabled ? null : onTap,
|
||||
@@ -1256,8 +1463,8 @@ class _ServiceChip extends StatelessWidget {
|
||||
color: isDisabled
|
||||
? colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
@@ -1270,8 +1477,8 @@ class _ServiceChip extends StatelessWidget {
|
||||
color: isDisabled
|
||||
? colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (isDisabled && disabledReason != null)
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class LibrarySettingsPage extends ConsumerStatefulWidget {
|
||||
@@ -21,6 +22,26 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasStoragePermission = false;
|
||||
|
||||
/// Convert SAF content URI to a readable display path
|
||||
String _getDisplayPath(String path) {
|
||||
if (!path.startsWith('content://')) return path;
|
||||
// Extract the path portion from SAF tree URI
|
||||
// e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic
|
||||
// -> /storage/emulated/0/Music
|
||||
try {
|
||||
final uri = Uri.parse(path);
|
||||
final treePath = uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
|
||||
final decoded = Uri.decodeComponent(treePath);
|
||||
if (decoded.startsWith('primary:')) {
|
||||
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
|
||||
}
|
||||
// For SD card or other volumes, just show the decoded path
|
||||
return decoded;
|
||||
} catch (_) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -33,19 +54,19 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
final sdkVersion = androidInfo.version.sdkInt;
|
||||
|
||||
// Check appropriate storage permission based on Android version
|
||||
bool hasPermission;
|
||||
if (sdkVersion >= 30) {
|
||||
hasPermission = await Permission.manageExternalStorage.isGranted;
|
||||
} else {
|
||||
hasPermission = await Permission.storage.isGranted;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_androidSdkVersion = sdkVersion;
|
||||
_hasStoragePermission = hasPermission;
|
||||
// SAF doesn't need storage permission on Android 10+
|
||||
_hasStoragePermission = sdkVersion >= 29 ? true : false;
|
||||
});
|
||||
// For older Android, check legacy storage permission
|
||||
if (sdkVersion < 29) {
|
||||
final hasPermission = await Permission.storage.isGranted;
|
||||
if (mounted) {
|
||||
setState(() => _hasStoragePermission = hasPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS doesn't need explicit storage permission for app documents
|
||||
@@ -55,13 +76,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
|
||||
Future<bool> _requestStoragePermission() async {
|
||||
if (Platform.isIOS) return true;
|
||||
// SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE
|
||||
if (_androidSdkVersion >= 29) return true;
|
||||
|
||||
PermissionStatus status;
|
||||
if (_androidSdkVersion >= 30) {
|
||||
status = await Permission.manageExternalStorage.request();
|
||||
} else {
|
||||
status = await Permission.storage.request();
|
||||
}
|
||||
final status = await Permission.storage.request();
|
||||
|
||||
if (status.isGranted) {
|
||||
setState(() => _hasStoragePermission = true);
|
||||
@@ -94,19 +112,30 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
}
|
||||
|
||||
Future<void> _pickLibraryFolder() async {
|
||||
// Request permission first
|
||||
if (!_hasStoragePermission) {
|
||||
final granted = await _requestStoragePermission();
|
||||
if (!granted) return;
|
||||
}
|
||||
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
|
||||
if (Platform.isAndroid && _androidSdkVersion >= 29) {
|
||||
// Use SAF tree picker - no MANAGE_EXTERNAL_STORAGE needed
|
||||
final result = await PlatformBridge.pickSafTree();
|
||||
if (result != null) {
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
if (treeUri.isNotEmpty) {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryPath(treeUri);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy: request permission and use file picker for older Android / iOS
|
||||
if (!_hasStoragePermission) {
|
||||
final granted = await _requestStoragePermission();
|
||||
if (!granted) return;
|
||||
}
|
||||
// Fallback for older devices
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
Future<void> _startScan({bool forceFullScan = false}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final libraryPath = settings.localLibraryPath;
|
||||
|
||||
@@ -117,7 +146,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await Directory(libraryPath).exists()) {
|
||||
if (!libraryPath.startsWith('content://') &&
|
||||
!await Directory(libraryPath).exists()) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.libraryFolderNotExist)),
|
||||
@@ -126,7 +156,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(localLibraryProvider.notifier).startScan(libraryPath);
|
||||
await ref.read(localLibraryProvider.notifier).startScan(
|
||||
libraryPath,
|
||||
forceFullScan: forceFullScan,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _cancelScan() async {
|
||||
@@ -231,6 +264,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
scanProgress: libraryState.scanProgress,
|
||||
scanCurrentFile: libraryState.scanCurrentFile,
|
||||
scanTotalFiles: libraryState.scanTotalFiles,
|
||||
scannedFiles: libraryState.scannedFiles,
|
||||
lastScannedAt: libraryState.lastScannedAt,
|
||||
),
|
||||
),
|
||||
@@ -262,7 +296,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
title: context.l10n.libraryFolder,
|
||||
subtitle: settings.localLibraryPath.isEmpty
|
||||
? context.l10n.libraryFolderHint
|
||||
: settings.localLibraryPath,
|
||||
: _getDisplayPath(settings.localLibraryPath),
|
||||
onTap: settings.localLibraryEnabled
|
||||
? _pickLibraryFolder
|
||||
: null,
|
||||
@@ -290,6 +324,53 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.libraryActions),
|
||||
),
|
||||
if (libraryState.scanWasCancelled)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Scan cancelled',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'You can retry the scan when ready.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _startScan,
|
||||
child: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
@@ -300,7 +381,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
totalFiles: libraryState.scanTotalFiles,
|
||||
onCancel: _cancelScan,
|
||||
)
|
||||
else
|
||||
else ...[
|
||||
Opacity(
|
||||
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
@@ -314,6 +395,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.sync,
|
||||
title: context.l10n.libraryForceFullScan,
|
||||
subtitle: context.l10n.libraryForceFullScanSubtitle,
|
||||
onTap: settings.localLibraryPath.isNotEmpty
|
||||
? () => _startScan(forceFullScan: true)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
Opacity(
|
||||
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
@@ -404,6 +497,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
final int scannedFiles;
|
||||
final DateTime? lastScannedAt;
|
||||
|
||||
const _LibraryHeroCard({
|
||||
@@ -412,6 +506,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
required this.scanProgress,
|
||||
this.scanCurrentFile,
|
||||
required this.scanTotalFiles,
|
||||
required this.scannedFiles,
|
||||
this.lastScannedAt,
|
||||
});
|
||||
|
||||
@@ -536,7 +631,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
itemCount.toString(),
|
||||
isScanning ? scannedFiles.toString() : itemCount.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -548,10 +643,12 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n
|
||||
.libraryTracksCount(itemCount)
|
||||
.replaceAll(itemCount.toString(), '')
|
||||
.trim(), // Getting just the label part if possible, or just use the full string if not
|
||||
isScanning
|
||||
? context.l10n.libraryTracksCount(scannedFiles).replaceAll(scannedFiles.toString(), '').trim()
|
||||
: context.l10n
|
||||
.libraryTracksCount(itemCount)
|
||||
.replaceAll(itemCount.toString(), '')
|
||||
.trim(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -24,46 +24,48 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.optionsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
title: Text(
|
||||
context.l10n.optionsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionSearchSource,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -86,14 +88,18 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.optionsSpotifyWarning,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
@@ -107,7 +113,11 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
icon: Icons.key,
|
||||
title: context.l10n.optionsSpotifyCredentials,
|
||||
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,
|
||||
onTap: () =>
|
||||
_showSpotifyCredentialsDialog(context, ref, settings),
|
||||
@@ -168,7 +178,9 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionPerformance,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -222,6 +234,12 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
title: context.l10n.cleanupOrphanedDownloads,
|
||||
subtitle: context.l10n.cleanupOrphanedDownloadsSubtitle,
|
||||
onTap: () => _cleanupOrphanedDownloads(context, ref),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.delete_forever,
|
||||
title: context.l10n.optionsClearHistory,
|
||||
@@ -271,9 +289,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogClearHistoryTitle),
|
||||
content: Text(
|
||||
context.l10n.dialogClearHistoryMessage,
|
||||
),
|
||||
content: Text(context.l10n.dialogClearHistoryMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
@@ -283,17 +299,66 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarHistoryCleared)));
|
||||
ScaffoldMessenger.of(context).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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _cleanupOrphanedDownloads(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
// Show loading indicator
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Row(
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(width: 16),
|
||||
Text(context.l10n.cleanupOrphanedDownloads),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final removed = await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.cleanupOrphanedDownloads();
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context); // Close loading dialog
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
removed > 0
|
||||
? context.l10n.cleanupOrphanedDownloadsResult(removed)
|
||||
: context.l10n.cleanupOrphanedDownloadsNone,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context); // Close loading dialog
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSpotifyCredentialsDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -441,7 +506,11 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.setSpotifyCredentials(clientId, clientSecret);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarCredentialsSaved,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -472,7 +541,11 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.clearSpotifyCredentials();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarCredentialsCleared,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
@@ -530,7 +603,9 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||
Text(
|
||||
currentValue == 1
|
||||
? context.l10n.optionsConcurrentSequential
|
||||
: context.l10n.optionsConcurrentParallel(currentValue),
|
||||
: context.l10n.optionsConcurrentParallel(
|
||||
currentValue,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -560,6 +635,18 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||
isSelected: currentValue == 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),
|
||||
@@ -785,20 +872,21 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
|
||||
Extension? activeExtension;
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
if (settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty) {
|
||||
activeExtension = extState.extensions
|
||||
.where((e) => e.id == settings.searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
}
|
||||
final hasExtensionSearch = activeExtension != null;
|
||||
|
||||
|
||||
String? extensionName;
|
||||
if (hasExtensionSearch) {
|
||||
extensionName = activeExtension.displayName;
|
||||
}
|
||||
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -816,8 +904,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
? context.l10n.optionsUsingExtension(extensionName!)
|
||||
: context.l10n.optionsPrimaryProviderSubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: hasExtensionSearch
|
||||
? colorScheme.primary
|
||||
color: hasExtensionSearch
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/donate_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
@@ -91,6 +92,12 @@ class SettingsTab extends ConsumerWidget {
|
||||
title: l10n.settingsExtensions,
|
||||
subtitle: l10n.settingsExtensionsSubtitle,
|
||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.favorite_outline,
|
||||
title: 'Donate',
|
||||
subtitle: 'Support SpotiFLAC-Mobile development',
|
||||
onTap: () => _navigateTo(context, const DonatePage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+587
-661
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,12 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
@@ -32,7 +31,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
||||
bool _lyricsLoading = false;
|
||||
String? _lyricsError;
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
|
||||
bool _isEmbedding = false; // Track embed operation in progress
|
||||
@@ -70,10 +68,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_checkFile();
|
||||
// Delay palette extraction to avoid jitter during initial build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_extractDominantColor();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -90,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 {
|
||||
var filePath = _filePath;
|
||||
if (filePath.startsWith('EXISTS:')) {
|
||||
@@ -128,9 +93,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool exists = false;
|
||||
int? size;
|
||||
try {
|
||||
final stat = await FileStat.stat(filePath);
|
||||
exists = stat.type != FileSystemEntityType.notFound;
|
||||
if (exists) {
|
||||
final stat = await fileStat(filePath);
|
||||
if (stat != null) {
|
||||
exists = true;
|
||||
size = stat.size;
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -185,7 +150,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5;
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
@@ -218,7 +182,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: _buildHeaderBackground(context, colorScheme, coverSize, bgColor, showContent),
|
||||
background: _buildHeaderBackground(context, colorScheme, coverSize, showContent),
|
||||
stretchModes: const [
|
||||
StretchMode.zoomBackground,
|
||||
StretchMode.blurBackground,
|
||||
@@ -286,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(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
// Blurred cover art background
|
||||
if (_coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||
)
|
||||
else if (_localCoverPath != null && _localCoverPath!.isNotEmpty)
|
||||
Image.file(
|
||||
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(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
@@ -1212,10 +1209,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (_isLocalItem) {
|
||||
// For local items, just delete the file
|
||||
try {
|
||||
final file = File(cleanFilePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
await deleteFile(cleanFilePath);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to delete file: $e');
|
||||
}
|
||||
@@ -1224,10 +1218,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
} else {
|
||||
// Existing download history deletion logic
|
||||
try {
|
||||
final file = File(cleanFilePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
await deleteFile(cleanFilePath);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to delete file: $e');
|
||||
}
|
||||
@@ -1249,13 +1240,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||
try {
|
||||
final mimeType = audioMimeTypeForPath(filePath);
|
||||
final result = await OpenFilex.open(filePath, type: mimeType);
|
||||
if (result.type != ResultType.done && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackCannotOpen(result.message))),
|
||||
);
|
||||
}
|
||||
await openFile(filePath);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1276,8 +1261,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _shareFile(BuildContext context) async {
|
||||
final file = File(cleanFilePath);
|
||||
if (!await file.exists()) {
|
||||
String sharePath = cleanFilePath;
|
||||
if (!await fileExists(sharePath)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarFileNotFound)),
|
||||
@@ -1285,11 +1270,27 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final shareTitle = '$trackName - $artistName';
|
||||
|
||||
// For SAF content URIs, use native share intent directly (zero-copy)
|
||||
if (isContentUri(sharePath)) {
|
||||
try {
|
||||
await PlatformBridge.shareContentUri(sharePath, title: shareTitle);
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('Failed to share file'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(cleanFilePath)],
|
||||
text: '$trackName - $artistName',
|
||||
files: [XFile(sharePath)],
|
||||
text: shareTitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
class TutorialScreen extends ConsumerStatefulWidget {
|
||||
const TutorialScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TutorialScreen> createState() => _TutorialScreenState();
|
||||
}
|
||||
|
||||
class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
static const int _totalPages = 6;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _nextPage() {
|
||||
if (_currentPage < _totalPages - 1) {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOutQuart,
|
||||
);
|
||||
} else {
|
||||
_completeTutorial();
|
||||
}
|
||||
}
|
||||
|
||||
void _prevPage() {
|
||||
_pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOutQuart,
|
||||
);
|
||||
}
|
||||
|
||||
void _completeTutorial() {
|
||||
ref.read(settingsProvider.notifier).setTutorialComplete();
|
||||
context.go('/');
|
||||
}
|
||||
|
||||
void _skipTutorial() {
|
||||
ref.read(settingsProvider.notifier).setTutorialComplete();
|
||||
context.go('/');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final l10n = context.l10n;
|
||||
final isLastPage = _currentPage == _totalPages - 1;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Top Navigation Bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity: _currentPage > 0 ? 1.0 : 0.0,
|
||||
child: IconButton.filledTonal(
|
||||
onPressed: _currentPage > 0 ? _prevPage : null,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Skip button
|
||||
TextButton(
|
||||
onPressed: _skipTutorial,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
l10n.setupSkip,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Main Content Area
|
||||
Expanded(
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: (page) => setState(() => _currentPage = page),
|
||||
children: [
|
||||
_TutorialPage(
|
||||
index: 0,
|
||||
currentIndex: _currentPage,
|
||||
icon: Icons.waving_hand_rounded,
|
||||
iconColor: Colors.amber,
|
||||
title: l10n.tutorialWelcomeTitle,
|
||||
description: l10n.tutorialWelcomeDesc,
|
||||
content: _buildFeatureList(context, [
|
||||
(Icons.music_note_rounded, l10n.tutorialWelcomeTip1),
|
||||
(Icons.high_quality_rounded, l10n.tutorialWelcomeTip2),
|
||||
(Icons.download_rounded, l10n.tutorialWelcomeTip3),
|
||||
]),
|
||||
),
|
||||
_TutorialPage(
|
||||
index: 1,
|
||||
currentIndex: _currentPage,
|
||||
icon: Icons.search_rounded,
|
||||
title: l10n.tutorialSearchTitle,
|
||||
description: l10n.tutorialSearchDesc,
|
||||
content: const _InteractiveSearchExample(),
|
||||
),
|
||||
_TutorialPage(
|
||||
index: 2,
|
||||
currentIndex: _currentPage,
|
||||
icon: Icons.download_rounded,
|
||||
title: l10n.tutorialDownloadTitle,
|
||||
description: l10n.tutorialDownloadDesc,
|
||||
content: const _InteractiveDownloadExample(),
|
||||
),
|
||||
_TutorialPage(
|
||||
index: 3,
|
||||
currentIndex: _currentPage,
|
||||
icon: Icons.library_music_rounded,
|
||||
title: l10n.tutorialLibraryTitle,
|
||||
description: l10n.tutorialLibraryDesc,
|
||||
content: _buildFeatureList(context, [
|
||||
(Icons.offline_pin_rounded, l10n.tutorialLibraryTip1),
|
||||
(Icons.play_circle_fill, l10n.tutorialLibraryTip2),
|
||||
(Icons.grid_view_rounded, l10n.tutorialLibraryTip3),
|
||||
]),
|
||||
),
|
||||
_TutorialPage(
|
||||
index: 4,
|
||||
currentIndex: _currentPage,
|
||||
icon: Icons.extension_rounded,
|
||||
title: l10n.tutorialExtensionsTitle,
|
||||
description: l10n.tutorialExtensionsDesc,
|
||||
content: _buildFeatureList(context, [
|
||||
(Icons.storefront_rounded, l10n.tutorialExtensionsTip1),
|
||||
(
|
||||
Icons.add_circle_outline_rounded,
|
||||
l10n.tutorialExtensionsTip2,
|
||||
),
|
||||
(Icons.lyrics_rounded, l10n.tutorialExtensionsTip3),
|
||||
]),
|
||||
),
|
||||
_TutorialPage(
|
||||
index: 5,
|
||||
currentIndex: _currentPage,
|
||||
icon: Icons.settings_rounded,
|
||||
title: l10n.tutorialSettingsTitle,
|
||||
description: l10n.tutorialSettingsDesc,
|
||||
content: Column(
|
||||
children: [
|
||||
_buildFeatureList(context, [
|
||||
(
|
||||
Icons.folder_open_rounded,
|
||||
l10n.tutorialSettingsTip1,
|
||||
),
|
||||
(Icons.tune_rounded, l10n.tutorialSettingsTip2),
|
||||
(Icons.palette_rounded, l10n.tutorialSettingsTip3),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_AnimatedReadyCard(text: l10n.tutorialReadyMessage),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom Control Area
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// Expressive Page Indicators
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(_totalPages, (index) {
|
||||
final isActive = _currentPage == index;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutBack,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
height: 8,
|
||||
width: isActive ? 32 : 8,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? colorScheme.primary
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Action Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: FilledButton(
|
||||
onPressed: _nextPage,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isLastPage ? l10n.setupGetStarted : l10n.setupNext,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureList(
|
||||
BuildContext context,
|
||||
List<(IconData, String)> features,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
children: features.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final feature = entry.value;
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: Duration(milliseconds: 600 + (index * 200)),
|
||||
curve: Curves.easeOutQuart,
|
||||
builder: (context, value, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - value)),
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
feature.$1,
|
||||
size: 24,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Text(
|
||||
feature.$2,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedReadyCard extends StatelessWidget {
|
||||
final String text;
|
||||
const _AnimatedReadyCard({required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_rounded,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InteractiveSearchExample extends StatefulWidget {
|
||||
const _InteractiveSearchExample();
|
||||
|
||||
@override
|
||||
State<_InteractiveSearchExample> createState() =>
|
||||
_InteractiveSearchExampleState();
|
||||
}
|
||||
|
||||
class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
bool _showResult = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Search Input
|
||||
TextField(
|
||||
controller: _controller,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showResult = value.isNotEmpty;
|
||||
});
|
||||
},
|
||||
style: TextStyle(color: colorScheme.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste or search...',
|
||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
prefixIcon: Icon(Icons.search, color: colorScheme.primary),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Result Placeholder
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutBack,
|
||||
child: _showResult
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.download_rounded,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InteractiveDownloadExample extends StatefulWidget {
|
||||
const _InteractiveDownloadExample();
|
||||
|
||||
@override
|
||||
State<_InteractiveDownloadExample> createState() =>
|
||||
_InteractiveDownloadExampleState();
|
||||
}
|
||||
|
||||
class _InteractiveDownloadExampleState
|
||||
extends State<_InteractiveDownloadExample> {
|
||||
bool _isDownloading = false;
|
||||
double _progress = 0.0;
|
||||
bool _isCompleted = false;
|
||||
|
||||
void _startDownload() async {
|
||||
if (_isDownloading || _isCompleted) return;
|
||||
|
||||
setState(() {
|
||||
_isDownloading = true;
|
||||
_progress = 0.0;
|
||||
});
|
||||
|
||||
for (int i = 0; i <= 100; i += 5) {
|
||||
if (!mounted) return;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
setState(() => _progress = i / 100);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_isCompleted = true;
|
||||
});
|
||||
|
||||
// Reset after a delay
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCompleted = false;
|
||||
_progress = 0.0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 36,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 140,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurface,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (_isDownloading)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
minHeight: 12,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 90,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
GestureDetector(
|
||||
onTap: _startDownload,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: _isCompleted ? Colors.green : colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (_isCompleted ? Colors.green : colorScheme.primary)
|
||||
.withValues(alpha: 0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _isDownloading
|
||||
? SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isCompleted
|
||||
? Icons.check_rounded
|
||||
: Icons.download_rounded,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TutorialPage extends StatelessWidget {
|
||||
final int index;
|
||||
final int currentIndex;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String description;
|
||||
final Widget content;
|
||||
final Color? iconColor;
|
||||
|
||||
const _TutorialPage({
|
||||
required this.index,
|
||||
required this.currentIndex,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.content,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Parallax effect logic (simplified for StatelessWidget)
|
||||
// In a real advanced implementation we'd pass the Controller's listenable
|
||||
// But for now, let's use entrance animations based on currentIndex == index
|
||||
|
||||
final isActive = currentIndex == index;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOutBack,
|
||||
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 56,
|
||||
color: iconColor ?? colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: isActive ? 1.0 : 0.0,
|
||||
curve: Curves.easeOut,
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: isActive ? 1.0 : 0.0,
|
||||
curve: Curves.easeOut,
|
||||
child: Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 56),
|
||||
content, // The content itself now handles its own internal animations
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class CsvImportService {
|
||||
final file = File(result.files.single.path!);
|
||||
final content = await file.readAsString();
|
||||
final tracks = _parseCsv(content);
|
||||
|
||||
|
||||
if (tracks.isNotEmpty) {
|
||||
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
|
||||
}
|
||||
@@ -39,43 +39,50 @@ class CsvImportService {
|
||||
}) async {
|
||||
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
|
||||
final enrichedTracks = <Track>[];
|
||||
|
||||
|
||||
for (int i = 0; i < tracks.length; i++) {
|
||||
final track = tracks[i];
|
||||
onProgress?.call(i + 1, tracks.length);
|
||||
|
||||
|
||||
if (track.coverUrl == null || track.duration == 0) {
|
||||
Map<String, dynamic>? trackData;
|
||||
|
||||
|
||||
if (track.isrc != null && track.isrc!.isNotEmpty) {
|
||||
try {
|
||||
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
|
||||
_log.d('ISRC enrichment success for ${track.name}');
|
||||
} 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) {
|
||||
try {
|
||||
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')) {
|
||||
final tracksList = searchResult['tracks'] as List<dynamic>?;
|
||||
if (tracksList != null && tracksList.isNotEmpty) {
|
||||
for (final result in tracksList) {
|
||||
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();
|
||||
|
||||
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
|
||||
|
||||
if (resultName.contains(trackNameLower) ||
|
||||
trackNameLower.contains(resultName)) {
|
||||
trackData = resultMap;
|
||||
_log.d('Text search match for ${track.name}: $resultName');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (trackData == null && tracksList.isNotEmpty) {
|
||||
trackData = tracksList.first as Map<String, dynamic>;
|
||||
_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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (trackData != null) {
|
||||
final coverUrl = trackData['images'] as String?;
|
||||
final durationMs = trackData['duration_ms'] as int? ?? 0;
|
||||
final deezerIdRaw = trackData['spotify_id'] as String?;
|
||||
|
||||
enrichedTracks.add(Track(
|
||||
id: deezerIdRaw ?? track.id,
|
||||
name: trackData['name'] as String? ?? track.name,
|
||||
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||
albumArtist: trackData['album_artist'] as String?,
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||
trackNumber: trackData['track_number'] as int? ?? track.trackNumber,
|
||||
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
|
||||
releaseDate: trackData['release_date'] as String? ?? track.releaseDate,
|
||||
));
|
||||
|
||||
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
|
||||
|
||||
|
||||
enrichedTracks.add(
|
||||
Track(
|
||||
id: deezerIdRaw ?? track.id,
|
||||
name: trackData['name'] as String? ?? track.name,
|
||||
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||
albumArtist: trackData['album_artist'] as String?,
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||
trackNumber:
|
||||
trackData['track_number'] as int? ?? track.trackNumber,
|
||||
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
|
||||
releaseDate:
|
||||
trackData['release_date'] as String? ?? track.releaseDate,
|
||||
),
|
||||
);
|
||||
|
||||
_log.d(
|
||||
'Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s',
|
||||
);
|
||||
|
||||
if (i < tracks.length - 1) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enrichedTracks.add(track);
|
||||
}
|
||||
|
||||
|
||||
_log.i('Enrichment complete: ${enrichedTracks.length} tracks');
|
||||
return enrichedTracks;
|
||||
}
|
||||
@@ -136,8 +149,8 @@ class CsvImportService {
|
||||
final headers = _parseLine(lines[startIdx]);
|
||||
final colMap = <String, int>{};
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
String h = _cleanValue(headers[i]).toLowerCase();
|
||||
colMap[h] = i;
|
||||
String h = _cleanValue(headers[i]).toLowerCase();
|
||||
colMap[h] = i;
|
||||
}
|
||||
|
||||
_log.d('CSV Headers: ${colMap.keys.toList()}');
|
||||
@@ -147,48 +160,67 @@ class CsvImportService {
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
final values = _parseLine(line);
|
||||
|
||||
|
||||
String? getVal(List<String> keys) {
|
||||
return _getValue(values, colMap, keys);
|
||||
}
|
||||
|
||||
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? 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:')) {
|
||||
spotifyId = spotifyId.replaceAll('spotify:track:', '');
|
||||
}
|
||||
|
||||
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
|
||||
tracks.add(Track(
|
||||
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
|
||||
name: trackName ?? 'Unknown Track',
|
||||
artistName: artistName ?? 'Unknown Artist',
|
||||
albumName: albumName ?? 'Unknown Album',
|
||||
isrc: isrc,
|
||||
duration: 0, // Will be updated by enrichment later
|
||||
coverUrl: null, // Will be fetched by enrichment
|
||||
));
|
||||
if ((trackName != null && trackName.isNotEmpty && artistName != null) ||
|
||||
(spotifyId != null && spotifyId.isNotEmpty)) {
|
||||
tracks.add(
|
||||
Track(
|
||||
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
|
||||
name: trackName ?? 'Unknown Track',
|
||||
artistName: artistName ?? 'Unknown Artist',
|
||||
albumName: albumName ?? 'Unknown Album',
|
||||
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');
|
||||
return tracks;
|
||||
}
|
||||
|
||||
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) {
|
||||
for (final key in possibleKeys) {
|
||||
if (colMap.containsKey(key)) {
|
||||
final index = colMap[key]!;
|
||||
if (index < values.length) {
|
||||
return _cleanValue(values[index]);
|
||||
}
|
||||
}
|
||||
|
||||
static String? _getValue(
|
||||
List<String> values,
|
||||
Map<String, int> colMap,
|
||||
List<String> possibleKeys,
|
||||
) {
|
||||
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) {
|
||||
@@ -201,30 +233,29 @@ class CsvImportService {
|
||||
}
|
||||
|
||||
static List<String> _parseLine(String line) {
|
||||
final List<String> result = [];
|
||||
bool inQuote = false;
|
||||
StringBuffer buffer = StringBuffer();
|
||||
|
||||
for (int i=0; i<line.length; i++) {
|
||||
String char = line[i];
|
||||
if (char == '"') {
|
||||
if (i + 1 < line.length && line[i+1] == '"') {
|
||||
buffer.write('"');
|
||||
buffer.write('"');
|
||||
i++; // Skip next quote char loop
|
||||
buffer.write('"'); // Write 2nd quote
|
||||
} else {
|
||||
inQuote = !inQuote;
|
||||
buffer.write(char);
|
||||
}
|
||||
} else if (char == ',' && !inQuote) {
|
||||
result.add(buffer.toString());
|
||||
buffer.clear();
|
||||
} else {
|
||||
buffer.write(char);
|
||||
}
|
||||
}
|
||||
result.add(buffer.toString());
|
||||
return result;
|
||||
final List<String> result = [];
|
||||
bool inQuote = false;
|
||||
var buffer = StringBuffer();
|
||||
|
||||
for (int i = 0; i < line.length; i++) {
|
||||
final char = line[i];
|
||||
if (char == '"') {
|
||||
if (inQuote && i + 1 < line.length && line[i + 1] == '"') {
|
||||
buffer.write('"');
|
||||
i++;
|
||||
} else {
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char == ',' && !inQuote) {
|
||||
result.add(buffer.toString());
|
||||
buffer = StringBuffer();
|
||||
continue;
|
||||
}
|
||||
buffer.write(char);
|
||||
}
|
||||
result.add(buffer.toString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+177
-110
@@ -10,12 +10,49 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
class FFmpegService {
|
||||
static const int _commandLogPreviewLength = 300;
|
||||
|
||||
static String _buildOutputPath(String inputPath, String extension) {
|
||||
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
|
||||
final inputFile = File(inputPath);
|
||||
final dir = inputFile.parent.path;
|
||||
final filename = inputFile.uri.pathSegments.last;
|
||||
final dotIndex = filename.lastIndexOf('.');
|
||||
final baseName = dotIndex > 0 ? filename.substring(0, dotIndex) : filename;
|
||||
var outputPath = '$dir${Platform.pathSeparator}$baseName$normalizedExt';
|
||||
|
||||
if (outputPath == inputPath) {
|
||||
outputPath =
|
||||
'$dir${Platform.pathSeparator}${baseName}_converted$normalizedExt';
|
||||
}
|
||||
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 {
|
||||
try {
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
final output = await session.getOutput() ?? '';
|
||||
|
||||
|
||||
return FFmpegResult(
|
||||
success: ReturnCode.isSuccess(returnCode),
|
||||
returnCode: returnCode?.getValue() ?? -1,
|
||||
@@ -28,7 +65,7 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
|
||||
final command =
|
||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||
@@ -59,10 +96,10 @@ class FFmpegService {
|
||||
bitrateValue = '${parts[1]}k';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final extension = format == 'opus' ? '.opus' : '.mp3';
|
||||
final outputPath = inputPath.replaceAll('.m4a', extension);
|
||||
|
||||
final outputPath = _buildOutputPath(inputPath, extension);
|
||||
|
||||
String command;
|
||||
if (format == 'opus') {
|
||||
command =
|
||||
@@ -92,7 +129,7 @@ class FFmpegService {
|
||||
String bitrate = '320k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = inputPath.replaceAll('.flac', '.mp3');
|
||||
final outputPath = _buildOutputPath(inputPath, '.mp3');
|
||||
|
||||
final command =
|
||||
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||
@@ -117,7 +154,7 @@ class FFmpegService {
|
||||
String bitrate = '128k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = inputPath.replaceAll('.flac', '.opus');
|
||||
final outputPath = _buildOutputPath(inputPath, '.opus');
|
||||
|
||||
final command =
|
||||
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
@@ -150,15 +187,27 @@ class FFmpegService {
|
||||
bitrateValue = '${parts[1]}k';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
switch (format.toLowerCase()) {
|
||||
case 'opus':
|
||||
final opusBitrate = bitrate?.startsWith('opus_') == true ? bitrateValue : '128k';
|
||||
return convertFlacToOpus(inputPath, bitrate: opusBitrate, deleteOriginal: deleteOriginal);
|
||||
final opusBitrate = bitrate?.startsWith('opus_') == true
|
||||
? bitrateValue
|
||||
: '128k';
|
||||
return convertFlacToOpus(
|
||||
inputPath,
|
||||
bitrate: opusBitrate,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
case 'mp3':
|
||||
default:
|
||||
final mp3Bitrate = bitrate?.startsWith('mp3_') == true ? bitrateValue : '320k';
|
||||
return convertFlacToMp3(inputPath, bitrate: mp3Bitrate, deleteOriginal: deleteOriginal);
|
||||
final mp3Bitrate = bitrate?.startsWith('mp3_') == true
|
||||
? bitrateValue
|
||||
: '320k';
|
||||
return convertFlacToMp3(
|
||||
inputPath,
|
||||
bitrate: mp3Bitrate,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +217,10 @@ class FFmpegService {
|
||||
String bitrate = '256k',
|
||||
}) async {
|
||||
final dir = File(inputPath).parent.path;
|
||||
final baseName =
|
||||
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||
final baseName = inputPath
|
||||
.split(Platform.pathSeparator)
|
||||
.last
|
||||
.replaceAll('.flac', '');
|
||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||
|
||||
await Directory(outputDir).create(recursive: true);
|
||||
@@ -220,16 +271,16 @@ class FFmpegService {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
|
||||
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$flacPath" ');
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v copy ');
|
||||
@@ -237,20 +288,20 @@ class FFmpegService {
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg command: $command');
|
||||
_log.d('Executing FFmpeg command: ${_previewCommandForLog(command)}');
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
@@ -258,20 +309,19 @@ class FFmpegService {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
final originalFile = File(flacPath);
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(flacPath);
|
||||
await tempFile.delete();
|
||||
|
||||
return flacPath;
|
||||
} else {
|
||||
_log.e('Temp output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(flacPath);
|
||||
await tempFile.delete();
|
||||
|
||||
return flacPath;
|
||||
} else {
|
||||
_log.e('Temp output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace file after metadata embed: $e');
|
||||
return null;
|
||||
@@ -299,16 +349,16 @@ class FFmpegService {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
|
||||
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$mp3Path" ');
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v:0 copy ');
|
||||
@@ -316,9 +366,9 @@ class FFmpegService {
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
|
||||
if (metadata != null) {
|
||||
final id3Metadata = _convertToId3Tags(metadata);
|
||||
id3Metadata.forEach((key, value) {
|
||||
@@ -326,11 +376,13 @@ class FFmpegService {
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
|
||||
|
||||
|
||||
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);
|
||||
|
||||
@@ -338,21 +390,20 @@ class FFmpegService {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
final originalFile = File(mp3Path);
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(mp3Path);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('MP3 metadata embedded successfully');
|
||||
return mp3Path;
|
||||
} else {
|
||||
_log.e('Temp MP3 output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(mp3Path);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('MP3 metadata embedded successfully');
|
||||
return mp3Path;
|
||||
} else {
|
||||
_log.e('Temp MP3 output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace MP3 file after metadata embed: $e');
|
||||
return null;
|
||||
@@ -380,26 +431,28 @@ class FFmpegService {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
|
||||
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$opusPath" ');
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
final pictureBlock = await _createMetadataBlockPicture(coverPath);
|
||||
if (pictureBlock != null) {
|
||||
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
|
||||
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
|
||||
_log.d(
|
||||
'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)',
|
||||
);
|
||||
} else {
|
||||
_log.w('Failed to create METADATA_BLOCK_PICTURE, skipping cover');
|
||||
}
|
||||
@@ -407,9 +460,9 @@ class FFmpegService {
|
||||
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg Opus embed command');
|
||||
|
||||
@@ -419,21 +472,20 @@ class FFmpegService {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
final originalFile = File(opusPath);
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(opusPath);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('Opus metadata embedded successfully');
|
||||
return opusPath;
|
||||
} else {
|
||||
_log.e('Temp Opus output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(opusPath);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('Opus metadata embedded successfully');
|
||||
return opusPath;
|
||||
} else {
|
||||
_log.e('Temp Opus output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace Opus file after metadata embed: $e');
|
||||
return null;
|
||||
@@ -460,81 +512,94 @@ class FFmpegService {
|
||||
_log.e('Cover image not found: $imagePath');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
final imageData = await file.readAsBytes();
|
||||
|
||||
|
||||
String mimeType;
|
||||
if (imagePath.toLowerCase().endsWith('.png')) {
|
||||
mimeType = 'image/png';
|
||||
} else if (imagePath.toLowerCase().endsWith('.jpg') ||
|
||||
imagePath.toLowerCase().endsWith('.jpeg')) {
|
||||
} else if (imagePath.toLowerCase().endsWith('.jpg') ||
|
||||
imagePath.toLowerCase().endsWith('.jpeg')) {
|
||||
mimeType = 'image/jpeg';
|
||||
} else {
|
||||
if (imageData.length >= 8 &&
|
||||
imageData[0] == 0x89 && imageData[1] == 0x50 &&
|
||||
imageData[2] == 0x4E && imageData[3] == 0x47) {
|
||||
if (imageData.length >= 8 &&
|
||||
imageData[0] == 0x89 &&
|
||||
imageData[1] == 0x50 &&
|
||||
imageData[2] == 0x4E &&
|
||||
imageData[3] == 0x47) {
|
||||
mimeType = 'image/png';
|
||||
} else if (imageData.length >= 2 &&
|
||||
imageData[0] == 0xFF && imageData[1] == 0xD8) {
|
||||
} else if (imageData.length >= 2 &&
|
||||
imageData[0] == 0xFF &&
|
||||
imageData[1] == 0xD8) {
|
||||
mimeType = 'image/jpeg';
|
||||
} else {
|
||||
mimeType = 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final mimeBytes = utf8.encode(mimeType);
|
||||
const description = '';
|
||||
final descBytes = utf8.encode(description);
|
||||
|
||||
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
|
||||
4 + 4 + 4 + 4 + 4 + imageData.length;
|
||||
|
||||
|
||||
final blockSize =
|
||||
4 +
|
||||
4 +
|
||||
mimeBytes.length +
|
||||
4 +
|
||||
descBytes.length +
|
||||
4 +
|
||||
4 +
|
||||
4 +
|
||||
4 +
|
||||
4 +
|
||||
imageData.length;
|
||||
|
||||
final buffer = ByteData(blockSize);
|
||||
var offset = 0;
|
||||
|
||||
|
||||
buffer.setUint32(offset, 3, Endian.big);
|
||||
offset += 4;
|
||||
|
||||
|
||||
buffer.setUint32(offset, mimeBytes.length, Endian.big);
|
||||
offset += 4;
|
||||
|
||||
|
||||
final blockBytes = Uint8List(blockSize);
|
||||
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
|
||||
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
|
||||
offset += mimeBytes.length;
|
||||
|
||||
|
||||
final tempBuffer = ByteData(4);
|
||||
tempBuffer.setUint32(0, descBytes.length, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
|
||||
offset += descBytes.length;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, imageData.length, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
blockBytes.setRange(offset, offset + imageData.length, imageData);
|
||||
|
||||
|
||||
final base64String = base64Encode(blockBytes);
|
||||
|
||||
|
||||
return base64String;
|
||||
} catch (e) {
|
||||
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
|
||||
@@ -542,13 +607,15 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
||||
static Map<String, String> _convertToId3Tags(
|
||||
Map<String, String> vorbisMetadata,
|
||||
) {
|
||||
final id3Map = <String, String>{};
|
||||
|
||||
|
||||
for (final entry in vorbisMetadata.entries) {
|
||||
final key = entry.key.toUpperCase();
|
||||
final value = entry.value;
|
||||
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
@@ -585,7 +652,7 @@ class FFmpegService {
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return id3Map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class HistoryDatabase {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
version: 3,
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
@@ -52,6 +52,11 @@ class HistoryDatabase {
|
||||
album_artist TEXT,
|
||||
cover_url TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
storage_mode TEXT,
|
||||
download_tree_uri TEXT,
|
||||
saf_relative_dir TEXT,
|
||||
saf_file_name TEXT,
|
||||
saf_repaired INTEGER,
|
||||
service TEXT NOT NULL,
|
||||
downloaded_at TEXT NOT NULL,
|
||||
isrc TEXT,
|
||||
@@ -80,7 +85,15 @@ class HistoryDatabase {
|
||||
|
||||
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
||||
_log.i('Upgrading database from v$oldVersion to v$newVersion');
|
||||
// Future migrations go here
|
||||
if (oldVersion < 2) {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN storage_mode TEXT');
|
||||
await db.execute('ALTER TABLE history ADD COLUMN download_tree_uri TEXT');
|
||||
await db.execute('ALTER TABLE history ADD COLUMN saf_relative_dir TEXT');
|
||||
await db.execute('ALTER TABLE history ADD COLUMN saf_file_name TEXT');
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== iOS Path Normalization ====================
|
||||
@@ -244,6 +257,11 @@ class HistoryDatabase {
|
||||
'album_artist': json['albumArtist'],
|
||||
'cover_url': json['coverUrl'],
|
||||
'file_path': json['filePath'],
|
||||
'storage_mode': json['storageMode'],
|
||||
'download_tree_uri': json['downloadTreeUri'],
|
||||
'saf_relative_dir': json['safRelativeDir'],
|
||||
'saf_file_name': json['safFileName'],
|
||||
'saf_repaired': json['safRepaired'] == true ? 1 : 0,
|
||||
'service': json['service'],
|
||||
'downloaded_at': json['downloadedAt'],
|
||||
'isrc': json['isrc'],
|
||||
@@ -272,6 +290,11 @@ class HistoryDatabase {
|
||||
'albumArtist': row['album_artist'],
|
||||
'coverUrl': row['cover_url'],
|
||||
'filePath': _normalizeIosPath(row['file_path'] as String?),
|
||||
'storageMode': row['storage_mode'],
|
||||
'downloadTreeUri': row['download_tree_uri'],
|
||||
'safRelativeDir': row['saf_relative_dir'],
|
||||
'safFileName': row['saf_file_name'],
|
||||
'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true,
|
||||
'service': row['service'],
|
||||
'downloadedAt': row['downloaded_at'],
|
||||
'isrc': row['isrc'],
|
||||
@@ -427,10 +450,46 @@ class HistoryDatabase {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close database
|
||||
/// Close database
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
|
||||
/// Get all file paths from download history
|
||||
/// Used to exclude downloaded files from local library scan
|
||||
Future<Set<String>> getAllFilePaths() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""'
|
||||
);
|
||||
return rows.map((r) => r['file_path'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Get all entries with file paths for orphan detection
|
||||
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
|
||||
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name
|
||||
FROM history
|
||||
WHERE file_path IS NOT NULL AND file_path != ""
|
||||
''');
|
||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
/// Delete multiple entries by IDs
|
||||
Future<int> deleteByIds(List<String> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
|
||||
final db = await database;
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
final count = await db.rawDelete(
|
||||
'DELETE FROM history WHERE id IN ($placeholders)',
|
||||
ids,
|
||||
);
|
||||
_log.i('Deleted $count orphaned entries');
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
|
||||
final _log = AppLogger('LibraryDatabase');
|
||||
|
||||
@@ -15,6 +15,7 @@ class LocalLibraryItem {
|
||||
final String filePath;
|
||||
final String? coverPath;
|
||||
final DateTime scannedAt;
|
||||
final int? fileModTime;
|
||||
final String? isrc;
|
||||
final int? trackNumber;
|
||||
final int? discNumber;
|
||||
@@ -34,6 +35,7 @@ class LocalLibraryItem {
|
||||
required this.filePath,
|
||||
this.coverPath,
|
||||
required this.scannedAt,
|
||||
this.fileModTime,
|
||||
this.isrc,
|
||||
this.trackNumber,
|
||||
this.discNumber,
|
||||
@@ -54,6 +56,7 @@ class LocalLibraryItem {
|
||||
'filePath': filePath,
|
||||
'coverPath': coverPath,
|
||||
'scannedAt': scannedAt.toIso8601String(),
|
||||
'fileModTime': fileModTime,
|
||||
'isrc': isrc,
|
||||
'trackNumber': trackNumber,
|
||||
'discNumber': discNumber,
|
||||
@@ -75,6 +78,7 @@ class LocalLibraryItem {
|
||||
filePath: json['filePath'] as String,
|
||||
coverPath: json['coverPath'] as String?,
|
||||
scannedAt: DateTime.parse(json['scannedAt'] as String),
|
||||
fileModTime: (json['fileModTime'] as num?)?.toInt(),
|
||||
isrc: json['isrc'] as String?,
|
||||
trackNumber: json['trackNumber'] as int?,
|
||||
discNumber: json['discNumber'] as int?,
|
||||
@@ -111,7 +115,7 @@ class LibraryDatabase {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 2, // Bumped version for cover_path migration
|
||||
version: 3, // Bumped version for file_mod_time migration
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
@@ -130,6 +134,7 @@ class LibraryDatabase {
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
scanned_at TEXT NOT NULL,
|
||||
file_mod_time INTEGER,
|
||||
isrc TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
@@ -158,6 +163,12 @@ class LibraryDatabase {
|
||||
await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT');
|
||||
_log.i('Added cover_path column');
|
||||
}
|
||||
|
||||
if (oldVersion < 3) {
|
||||
// Add file_mod_time column for incremental scanning
|
||||
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
|
||||
_log.i('Added file_mod_time column for incremental scanning');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
@@ -170,6 +181,7 @@ class LibraryDatabase {
|
||||
'file_path': json['filePath'],
|
||||
'cover_path': json['coverPath'],
|
||||
'scanned_at': json['scannedAt'],
|
||||
'file_mod_time': json['fileModTime'],
|
||||
'isrc': json['isrc'],
|
||||
'track_number': json['trackNumber'],
|
||||
'disc_number': json['discNumber'],
|
||||
@@ -192,6 +204,7 @@ class LibraryDatabase {
|
||||
'filePath': row['file_path'],
|
||||
'coverPath': row['cover_path'],
|
||||
'scannedAt': row['scanned_at'],
|
||||
'fileModTime': row['file_mod_time'],
|
||||
'isrc': row['isrc'],
|
||||
'trackNumber': row['track_number'],
|
||||
'discNumber': row['disc_number'],
|
||||
@@ -341,7 +354,7 @@ class LibraryDatabase {
|
||||
int removed = 0;
|
||||
for (final row in rows) {
|
||||
final filePath = row['file_path'] as String;
|
||||
if (!await File(filePath).exists()) {
|
||||
if (!await fileExists(filePath)) {
|
||||
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
|
||||
removed++;
|
||||
}
|
||||
@@ -383,4 +396,58 @@ class LibraryDatabase {
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
|
||||
/// Get all file paths with their modification times for incremental scanning
|
||||
/// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds)
|
||||
Future<Map<String, int>> getFileModTimes() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library'
|
||||
);
|
||||
final result = <String, int>{};
|
||||
for (final row in rows) {
|
||||
final path = row['file_path'] as String;
|
||||
final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0;
|
||||
result[path] = modTime;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Update file_mod_time for existing rows using file_path as key.
|
||||
Future<void> updateFileModTimes(Map<String, int> fileModTimes) async {
|
||||
if (fileModTimes.isEmpty) return;
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
for (final entry in fileModTimes.entries) {
|
||||
batch.update(
|
||||
'library',
|
||||
{'file_mod_time': entry.value},
|
||||
where: 'file_path = ?',
|
||||
whereArgs: [entry.key],
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
/// Get all file paths in the library (for detecting deleted files)
|
||||
Future<Set<String>> getAllFilePaths() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery('SELECT file_path FROM library');
|
||||
return rows.map((r) => r['file_path'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Delete multiple items by their file paths
|
||||
Future<int> deleteByPaths(List<String> filePaths) async {
|
||||
if (filePaths.isEmpty) return 0;
|
||||
final db = await database;
|
||||
final placeholders = List.filled(filePaths.length, '?').join(',');
|
||||
final result = await db.rawDelete(
|
||||
'DELETE FROM library WHERE file_path IN ($placeholders)',
|
||||
filePaths,
|
||||
);
|
||||
if (result > 0) {
|
||||
_log.i('Deleted $result items from library');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class NotificationService {
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _notifications.initialize(initSettings);
|
||||
await _notifications.initialize(settings: initSettings);
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
await _notifications
|
||||
@@ -90,10 +90,10 @@ class NotificationService {
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
downloadProgressId,
|
||||
'Downloading $trackName',
|
||||
'$artistName • $percentage%',
|
||||
details,
|
||||
id: downloadProgressId,
|
||||
title: 'Downloading $trackName',
|
||||
body: '$artistName • $percentage%',
|
||||
notificationDetails: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,10 +133,10 @@ class NotificationService {
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
downloadProgressId,
|
||||
'Finalizing $trackName',
|
||||
'$artistName • Embedding metadata...',
|
||||
details,
|
||||
id: downloadProgressId,
|
||||
title: 'Finalizing $trackName',
|
||||
body: '$artistName • Embedding metadata...',
|
||||
notificationDetails: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,10 +183,10 @@ class NotificationService {
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
downloadProgressId,
|
||||
title,
|
||||
'$trackName - $artistName',
|
||||
details,
|
||||
id: downloadProgressId,
|
||||
title: title,
|
||||
body: '$trackName - $artistName',
|
||||
notificationDetails: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,15 +223,15 @@ class NotificationService {
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
downloadProgressId,
|
||||
title,
|
||||
'$completedCount tracks downloaded successfully',
|
||||
details,
|
||||
id: downloadProgressId,
|
||||
title: title,
|
||||
body: '$completedCount tracks downloaded successfully',
|
||||
notificationDetails: details,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelDownloadNotification() async {
|
||||
await _notifications.cancel(downloadProgressId);
|
||||
await _notifications.cancel(id: downloadProgressId);
|
||||
}
|
||||
|
||||
Future<void> showUpdateDownloadProgress({
|
||||
@@ -274,10 +274,10 @@ class NotificationService {
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
updateDownloadId,
|
||||
'Downloading SpotiFLAC v$version',
|
||||
'$receivedMB / $totalMB MB • $percentage%',
|
||||
details,
|
||||
id: updateDownloadId,
|
||||
title: 'Downloading SpotiFLAC v$version',
|
||||
body: '$receivedMB / $totalMB MB • $percentage%',
|
||||
notificationDetails: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -307,10 +307,10 @@ class NotificationService {
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
updateDownloadId,
|
||||
'Update Ready',
|
||||
'SpotiFLAC v$version downloaded. Tap to install.',
|
||||
details,
|
||||
id: updateDownloadId,
|
||||
title: 'Update Ready',
|
||||
body: 'SpotiFLAC v$version downloaded. Tap to install.',
|
||||
notificationDetails: details,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -339,14 +339,14 @@ class NotificationService {
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
updateDownloadId,
|
||||
'Update Failed',
|
||||
'Could not download update. Try again later.',
|
||||
details,
|
||||
id: updateDownloadId,
|
||||
title: 'Update Failed',
|
||||
body: 'Could not download update. Try again later.',
|
||||
notificationDetails: details,
|
||||
);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
+1108
-930
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
|
||||
class FileAccessStat {
|
||||
final int? size;
|
||||
final DateTime? modified;
|
||||
|
||||
const FileAccessStat({this.size, this.modified});
|
||||
}
|
||||
|
||||
bool isContentUri(String? path) {
|
||||
return path != null && path.startsWith('content://');
|
||||
}
|
||||
|
||||
Future<bool> fileExists(String? path) async {
|
||||
if (path == null || path.isEmpty) return false;
|
||||
if (isContentUri(path)) {
|
||||
return PlatformBridge.safExists(path);
|
||||
}
|
||||
return File(path).exists();
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String? path) async {
|
||||
if (path == null || path.isEmpty) return;
|
||||
if (isContentUri(path)) {
|
||||
await PlatformBridge.safDelete(path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(path).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<FileAccessStat?> fileStat(String? path) async {
|
||||
if (path == null || path.isEmpty) return null;
|
||||
if (isContentUri(path)) {
|
||||
final stat = await PlatformBridge.safStat(path);
|
||||
final exists = stat['exists'] as bool? ?? true;
|
||||
if (!exists) return null;
|
||||
return FileAccessStat(
|
||||
size: stat['size'] as int?,
|
||||
modified: stat['modified'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(stat['modified'] as int)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
final stat = await FileStat.stat(path);
|
||||
if (stat.type == FileSystemEntityType.notFound) return null;
|
||||
return FileAccessStat(size: stat.size, modified: stat.modified);
|
||||
}
|
||||
|
||||
Future<void> openFile(String path) async {
|
||||
if (isContentUri(path)) {
|
||||
await PlatformBridge.openContentUri(path, mimeType: '');
|
||||
return;
|
||||
}
|
||||
final mimeType = audioMimeTypeForPath(path);
|
||||
final result = await OpenFilex.open(path, type: mimeType);
|
||||
if (result.type != ResultType.done) {
|
||||
throw Exception(result.message);
|
||||
}
|
||||
}
|
||||
+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/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 {
|
||||
final DateTime timestamp;
|
||||
final String level;
|
||||
@@ -46,10 +55,11 @@ class LogBuffer extends ChangeNotifier {
|
||||
LogBuffer._internal();
|
||||
|
||||
static const int maxEntries = 500;
|
||||
static const Duration _goLogPollingInterval = Duration(milliseconds: 800);
|
||||
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
||||
Timer? _goLogTimer;
|
||||
int _lastGoLogIndex = 0;
|
||||
|
||||
|
||||
static bool _loggingEnabled = false;
|
||||
static bool get loggingEnabled => _loggingEnabled;
|
||||
static set loggingEnabled(bool value) {
|
||||
@@ -68,17 +78,33 @@ class LogBuffer extends ChangeNotifier {
|
||||
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
||||
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) {
|
||||
_entries.removeFirst();
|
||||
}
|
||||
_entries.add(entry);
|
||||
_entries.add(sanitizedEntry);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
|
||||
await _fetchGoLogs();
|
||||
});
|
||||
}
|
||||
@@ -93,13 +119,13 @@ class LogBuffer extends ChangeNotifier {
|
||||
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||
final logs = result['logs'] as List<dynamic>? ?? [];
|
||||
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
||||
|
||||
|
||||
for (final log in logs) {
|
||||
final timestamp = log['timestamp'] as String? ?? '';
|
||||
final level = log['level'] as String? ?? 'INFO';
|
||||
final tag = log['tag'] as String? ?? 'Go';
|
||||
final message = log['message'] as String? ?? '';
|
||||
|
||||
|
||||
DateTime parsedTime = DateTime.now();
|
||||
if (timestamp.isNotEmpty) {
|
||||
try {
|
||||
@@ -107,25 +133,29 @@ class LogBuffer extends ChangeNotifier {
|
||||
if (parts.length >= 3) {
|
||||
final secParts = parts[2].split('.');
|
||||
parsedTime = DateTime(
|
||||
parsedTime.year, parsedTime.month, parsedTime.day,
|
||||
int.parse(parts[0]), int.parse(parts[1]),
|
||||
parsedTime.year,
|
||||
parsedTime.month,
|
||||
parsedTime.day,
|
||||
int.parse(parts[0]),
|
||||
int.parse(parts[1]),
|
||||
int.parse(secParts[0]),
|
||||
secParts.length > 1 ? int.parse(secParts[1]) : 0,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
add(LogEntry(
|
||||
timestamp: parsedTime,
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
isFromGo: true,
|
||||
));
|
||||
|
||||
add(
|
||||
LogEntry(
|
||||
timestamp: parsedTime,
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
isFromGo: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
_lastGoLogIndex = nextIndex;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
@@ -156,27 +186,31 @@ class LogBuffer extends ChangeNotifier {
|
||||
|
||||
Future<String> exportWithDeviceInfo() async {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln('SPOTIFLAC LOG EXPORT');
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln();
|
||||
|
||||
|
||||
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();
|
||||
|
||||
|
||||
buffer.writeln('--- Device Information ---');
|
||||
try {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final android = await deviceInfo.androidInfo;
|
||||
buffer.writeln('Platform: Android');
|
||||
buffer.writeln('Device: ${android.manufacturer} ${android.model}');
|
||||
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('Hardware: ${android.hardware}');
|
||||
buffer.writeln('Product: ${android.product}');
|
||||
@@ -196,16 +230,16 @@ class LogBuffer extends ChangeNotifier {
|
||||
buffer.writeln('Failed to get device info: $e');
|
||||
}
|
||||
buffer.writeln();
|
||||
|
||||
|
||||
buffer.writeln('--- Log Summary ---');
|
||||
buffer.writeln('Total Entries: ${_entries.length}');
|
||||
|
||||
|
||||
int errorCount = 0;
|
||||
int warnCount = 0;
|
||||
int infoCount = 0;
|
||||
int debugCount = 0;
|
||||
int goCount = 0;
|
||||
|
||||
|
||||
for (final entry in _entries) {
|
||||
switch (entry.level) {
|
||||
case 'ERROR':
|
||||
@@ -224,23 +258,23 @@ class LogBuffer extends ChangeNotifier {
|
||||
}
|
||||
if (entry.isFromGo) goCount++;
|
||||
}
|
||||
|
||||
|
||||
buffer.writeln('Errors: $errorCount');
|
||||
buffer.writeln('Warnings: $warnCount');
|
||||
buffer.writeln('Info: $infoCount');
|
||||
buffer.writeln('Debug: $debugCount');
|
||||
buffer.writeln('From Go Backend: $goCount');
|
||||
buffer.writeln();
|
||||
|
||||
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln('LOG ENTRIES');
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln();
|
||||
|
||||
|
||||
for (final entry in _entries) {
|
||||
buffer.writeln(entry.toString());
|
||||
}
|
||||
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@@ -274,19 +308,21 @@ class BufferedOutput extends LogOutput {
|
||||
void output(OutputEvent event) {
|
||||
if (kDebugMode) {
|
||||
for (final line in event.lines) {
|
||||
debugPrint(line);
|
||||
debugPrint(_truncateLogText(line));
|
||||
}
|
||||
}
|
||||
|
||||
final level = _levelToString(event.level);
|
||||
final message = event.lines.join('\n');
|
||||
|
||||
LogBuffer().add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
));
|
||||
final message = _truncateLogText(event.lines.join('\n'));
|
||||
|
||||
LogBuffer().add(
|
||||
LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _levelToString(Level level) {
|
||||
@@ -336,13 +372,15 @@ class AppLogger {
|
||||
}
|
||||
|
||||
void _addToBuffer(String level, String message, {String? error}) {
|
||||
LogBuffer().add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: _tag,
|
||||
message: message,
|
||||
error: error,
|
||||
));
|
||||
LogBuffer().add(
|
||||
LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: _tag,
|
||||
message: message,
|
||||
error: error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void d(String message) {
|
||||
@@ -373,7 +411,9 @@ class AppLogger {
|
||||
if (error != null) {
|
||||
_addToBuffer('ERROR', message, error: error.toString());
|
||||
if (kDebugMode) {
|
||||
debugPrint('[$_tag] ERROR: $message | $error');
|
||||
debugPrint(
|
||||
'[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}',
|
||||
);
|
||||
if (stackTrace != null) {
|
||||
debugPrint(stackTrace.toString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Custom painted icons for donate platforms
|
||||
|
||||
class KofiIcon extends StatelessWidget {
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
const KofiIcon({super.key, this.size = 22, this.color = Colors.white});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
size: Size(size, size),
|
||||
painter: _KofiPainter(color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KofiPainter extends CustomPainter {
|
||||
final Color color;
|
||||
_KofiPainter(this.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final s = size.width;
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// Cup body
|
||||
final cup = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(s * 0.08, s * 0.28, s * 0.62, s * 0.52),
|
||||
Radius.circular(s * 0.12),
|
||||
);
|
||||
canvas.drawRRect(cup, paint);
|
||||
|
||||
// Handle
|
||||
final handlePaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = s * 0.08
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final handlePath = Path()
|
||||
..moveTo(s * 0.70, s * 0.40)
|
||||
..quadraticBezierTo(s * 0.92, s * 0.40, s * 0.92, s * 0.54)
|
||||
..quadraticBezierTo(s * 0.92, s * 0.68, s * 0.70, s * 0.68);
|
||||
canvas.drawPath(handlePath, handlePaint);
|
||||
|
||||
// Heart on cup
|
||||
final heartPaint = Paint()
|
||||
..color = const Color(0xFFFF5E5B)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final hx = s * 0.39;
|
||||
final hy = s * 0.46;
|
||||
final hs = s * 0.12;
|
||||
|
||||
final heart = Path()
|
||||
..moveTo(hx, hy + hs * 0.3)
|
||||
..cubicTo(hx - hs, hy - hs * 0.3, hx - hs * 0.5, hy - hs, hx, hy - hs * 0.4)
|
||||
..cubicTo(hx + hs * 0.5, hy - hs, hx + hs, hy - hs * 0.3, hx, hy + hs * 0.3)
|
||||
..close();
|
||||
canvas.drawPath(heart, heartPaint);
|
||||
|
||||
// Steam lines
|
||||
final steamPaint = Paint()
|
||||
..color = color.withValues(alpha: 0.6)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = s * 0.04
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
for (var i = 0; i < 2; i++) {
|
||||
final sx = s * (0.30 + i * 0.18);
|
||||
final steam = Path()
|
||||
..moveTo(sx, s * 0.24)
|
||||
..quadraticBezierTo(sx - s * 0.04, s * 0.18, sx, s * 0.12);
|
||||
canvas.drawPath(steam, steamPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class BmacIcon extends StatelessWidget {
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
const BmacIcon({super.key, this.size = 22, this.color = Colors.black87});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
size: Size(size, size),
|
||||
painter: _BmacPainter(color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BmacPainter extends CustomPainter {
|
||||
final Color color;
|
||||
_BmacPainter(this.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final s = size.width;
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// Cup body (slightly tapered)
|
||||
final cup = Path()
|
||||
..moveTo(s * 0.15, s * 0.35)
|
||||
..lineTo(s * 0.20, s * 0.82)
|
||||
..quadraticBezierTo(s * 0.20, s * 0.90, s * 0.28, s * 0.90)
|
||||
..lineTo(s * 0.56, s * 0.90)
|
||||
..quadraticBezierTo(s * 0.64, s * 0.90, s * 0.64, s * 0.82)
|
||||
..lineTo(s * 0.69, s * 0.35)
|
||||
..close();
|
||||
canvas.drawPath(cup, paint);
|
||||
|
||||
// Cup rim
|
||||
final rim = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(s * 0.10, s * 0.30, s * 0.64, s * 0.10),
|
||||
Radius.circular(s * 0.05),
|
||||
);
|
||||
canvas.drawRRect(rim, paint);
|
||||
|
||||
// Handle
|
||||
final handlePaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = s * 0.07
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final handle = Path()
|
||||
..moveTo(s * 0.69, s * 0.42)
|
||||
..quadraticBezierTo(s * 0.90, s * 0.42, s * 0.90, s * 0.56)
|
||||
..quadraticBezierTo(s * 0.90, s * 0.70, s * 0.69, s * 0.70);
|
||||
canvas.drawPath(handle, handlePaint);
|
||||
|
||||
// Steam
|
||||
final steamPaint = Paint()
|
||||
..color = color.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = s * 0.04
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final sx = s * (0.26 + i * 0.14);
|
||||
final steam = Path()
|
||||
..moveTo(sx, s * 0.26)
|
||||
..quadraticBezierTo(sx + s * 0.03, s * 0.18, sx, s * 0.10);
|
||||
canvas.drawPath(steam, steamPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class GitHubIcon extends StatelessWidget {
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
const GitHubIcon({super.key, this.size = 22, this.color = Colors.white});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
size: Size(size, size),
|
||||
painter: _GitHubPainter(color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GitHubPainter extends CustomPainter {
|
||||
final Color color;
|
||||
_GitHubPainter(this.color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final s = size.width;
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// GitHub octocat silhouette (simplified mark)
|
||||
// Based on the GitHub logo path, scaled to fit
|
||||
final scale = s / 24.0;
|
||||
|
||||
final path = Path();
|
||||
// Outer circle/head shape
|
||||
path.moveTo(12 * scale, 0.5 * scale);
|
||||
path.cubicTo(
|
||||
5.37 * scale, 0.5 * scale,
|
||||
0 * scale, 5.87 * scale,
|
||||
0 * scale, 12.5 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
0 * scale, 17.78 * scale,
|
||||
3.44 * scale, 22.27 * scale,
|
||||
8.21 * scale, 23.85 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
8.81 * scale, 23.96 * scale,
|
||||
9.02 * scale, 23.59 * scale,
|
||||
9.02 * scale, 23.27 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
9.02 * scale, 22.98 * scale,
|
||||
9.01 * scale, 22.01 * scale,
|
||||
9.01 * scale, 21.01 * scale,
|
||||
);
|
||||
// Left arm
|
||||
path.cubicTo(
|
||||
5.67 * scale, 21.71 * scale,
|
||||
4.97 * scale, 19.56 * scale,
|
||||
4.97 * scale, 19.56 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
4.42 * scale, 18.22 * scale,
|
||||
3.63 * scale, 17.85 * scale,
|
||||
3.63 * scale, 17.85 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
2.55 * scale, 17.12 * scale,
|
||||
3.71 * scale, 17.13 * scale,
|
||||
3.71 * scale, 17.13 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
4.90 * scale, 17.22 * scale,
|
||||
5.53 * scale, 18.36 * scale,
|
||||
5.53 * scale, 18.36 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
6.58 * scale, 20.05 * scale,
|
||||
8.36 * scale, 19.53 * scale,
|
||||
9.05 * scale, 19.22 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
9.16 * scale, 18.45 * scale,
|
||||
9.47 * scale, 17.93 * scale,
|
||||
9.81 * scale, 17.63 * scale,
|
||||
);
|
||||
// Bottom
|
||||
path.cubicTo(
|
||||
7.15 * scale, 17.33 * scale,
|
||||
4.34 * scale, 16.33 * scale,
|
||||
4.34 * scale, 11.93 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
4.34 * scale, 10.68 * scale,
|
||||
4.81 * scale, 9.66 * scale,
|
||||
5.55 * scale, 8.86 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
5.43 * scale, 8.56 * scale,
|
||||
5.02 * scale, 7.40 * scale,
|
||||
5.67 * scale, 5.82 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
5.67 * scale, 5.82 * scale,
|
||||
6.66 * scale, 5.50 * scale,
|
||||
8.98 * scale, 6.99 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
9.94 * scale, 6.72 * scale,
|
||||
10.98 * scale, 6.59 * scale,
|
||||
12.0 * scale, 6.58 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
13.02 * scale, 6.59 * scale,
|
||||
14.06 * scale, 6.72 * scale,
|
||||
15.02 * scale, 6.99 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
17.34 * scale, 5.50 * scale,
|
||||
18.33 * scale, 5.82 * scale,
|
||||
18.33 * scale, 5.82 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
18.98 * scale, 7.40 * scale,
|
||||
18.57 * scale, 8.56 * scale,
|
||||
18.45 * scale, 8.86 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
19.19 * scale, 9.66 * scale,
|
||||
19.66 * scale, 10.68 * scale,
|
||||
19.66 * scale, 11.93 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
19.66 * scale, 16.34 * scale,
|
||||
16.84 * scale, 17.32 * scale,
|
||||
14.17 * scale, 17.62 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
14.59 * scale, 17.99 * scale,
|
||||
14.97 * scale, 18.70 * scale,
|
||||
14.97 * scale, 19.80 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
14.97 * scale, 21.40 * scale,
|
||||
14.95 * scale, 22.67 * scale,
|
||||
14.95 * scale, 23.27 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
14.95 * scale, 23.60 * scale,
|
||||
15.16 * scale, 23.97 * scale,
|
||||
15.77 * scale, 23.85 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
20.55 * scale, 22.26 * scale,
|
||||
24.0 * scale, 17.78 * scale,
|
||||
24.0 * scale, 12.5 * scale,
|
||||
);
|
||||
path.cubicTo(
|
||||
24.0 * scale, 5.87 * scale,
|
||||
18.63 * scale, 0.5 * scale,
|
||||
12.0 * scale, 0.5 * scale,
|
||||
);
|
||||
path.close();
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -334,6 +334,28 @@ Padding(
|
||||
widget.onSelect('HIGH', _selectedService);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: const Text('Opus 256kbps'),
|
||||
subtitle: const Text('Best quality Opus, ~8MB per track'),
|
||||
trailing: currentFormat == 'opus_256'
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256');
|
||||
Navigator.pop(modalContext); // Close format picker
|
||||
Navigator.pop(context); // Close service picker
|
||||
widget.onSelect('HIGH', _selectedService);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Container(
|
||||
@@ -345,7 +367,7 @@ Padding(
|
||||
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: const Text('Opus 128kbps'),
|
||||
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||
subtitle: const Text('Smallest size, ~4MB per track'),
|
||||
trailing: currentFormat == 'opus_128'
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: null,
|
||||
|
||||
+28
-36
@@ -189,10 +189,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
|
||||
sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
version: "7.0.0"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -386,34 +386,34 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
|
||||
sha256: "76cd20bcfa72fabe50ea27eeaf165527f446f55d3033021462084b87805b4cac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "19.5.0"
|
||||
version: "20.0.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
|
||||
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
version: "7.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
|
||||
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.0"
|
||||
version: "10.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
|
||||
sha256: "7ddd964fa85b6a23e96956c5b63ef55cdb9e5947b71b95712204db42ad46da61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "2.0.0"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -439,50 +439,50 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
version: "3.0.0"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "2.0.1"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
version: "2.1.0"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
version: "4.1.0"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -741,14 +741,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+4
-5
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.4.0+72
|
||||
version: 3.5.1+75
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -24,7 +24,7 @@ dependencies:
|
||||
|
||||
# Storage & Persistence
|
||||
shared_preferences: ^2.5.3
|
||||
flutter_secure_storage: ^9.2.2
|
||||
flutter_secure_storage: 10.0.0
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.0
|
||||
sqflite: ^2.4.1
|
||||
@@ -32,7 +32,7 @@ dependencies:
|
||||
# HTTP & Network
|
||||
http: ^1.6.0
|
||||
dio: ^5.8.0
|
||||
connectivity_plus: ^6.0.3
|
||||
connectivity_plus: 7.0.0
|
||||
|
||||
# UI Components
|
||||
cupertino_icons: ^1.0.8
|
||||
@@ -43,7 +43,6 @@ dependencies:
|
||||
# Material Expressive 3 / Dynamic Color
|
||||
dynamic_color: ^1.7.0
|
||||
material_color_utilities: ^0.11.1
|
||||
palette_generator: ^0.3.3+4
|
||||
|
||||
# Permissions
|
||||
permission_handler: ^12.0.1
|
||||
@@ -66,7 +65,7 @@ dependencies:
|
||||
open_filex: ^4.7.0
|
||||
|
||||
# Notifications
|
||||
flutter_local_notifications: ^19.0.0
|
||||
flutter_local_notifications: 20.0.0
|
||||
|
||||
dev_dependencies:
|
||||
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