mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 12:18:02 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ccda8db58 | |||
| 6d7b89b881 | |||
| 47777b4343 | |||
| 2eb1d2a65d | |||
| ce057c6473 | |||
| 46cfe8b632 | |||
| 2e5eff6e3d | |||
| dd506efeb6 | |||
| 8d92d22fda | |||
| b99764b1ad | |||
| 621582cf11 | |||
| b96233f90b | |||
| 65e21a421d | |||
| 87b33dda7e | |||
| 2f097c8f6c | |||
| 8cbdea1417 | |||
| 48bdd154f6 | |||
| ae0e157c34 | |||
| 53fcdd9a47 | |||
| 3d6be3bf92 | |||
| 2d7fba3f52 | |||
| e02d8ff2cd | |||
| f8cee25958 | |||
| 99c133aae1 |
@@ -6,6 +6,8 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
.cursorignore
|
||||||
|
.cursorrules
|
||||||
|
|
||||||
# Kiro specs (development only)
|
# Kiro specs (development only)
|
||||||
.kiro/
|
.kiro/
|
||||||
|
|||||||
+64
-14
@@ -1,6 +1,56 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [3.1.0] - 2026-01-19
|
## [3.1.1] - 2026-01-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Lyrics Caching**: Lyrics are now cached for 24 hours to reduce API calls and improve performance
|
||||||
|
- Thread-safe cache with automatic expiration
|
||||||
|
- Cache key based on artist, track, and duration
|
||||||
|
- Log indicator shows "(cached)" when lyrics are served from cache
|
||||||
|
|
||||||
|
- **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching
|
||||||
|
- Compares track duration with lrclib.net results
|
||||||
|
- 10-second tolerance to handle version differences (radio edit, remaster, etc.)
|
||||||
|
- Prioritizes synced lyrics over plain text when duration matches
|
||||||
|
- Falls back gracefully if no duration match found
|
||||||
|
|
||||||
|
- **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality
|
||||||
|
- Detects Deezer CDN URLs (`cdn-images.dzcdn.net`)
|
||||||
|
- Upgrades cover resolution to 1800x1800 (max available)
|
||||||
|
- Works alongside existing cover upgrade
|
||||||
|
|
||||||
|
- **Live Search for Extensions**: Search-as-you-type functionality for extension search
|
||||||
|
- 800ms debounce delay to prevent excessive API calls
|
||||||
|
- Minimum 3 characters required before searching
|
||||||
|
- Concurrency control to prevent race conditions in extension runtime
|
||||||
|
- Queues pending searches if a search is already in progress
|
||||||
|
|
||||||
|
- **Russian Language Support**: Added Russian (Русский) translation - 99% complete
|
||||||
|
- Translated via Crowdin community contributions
|
||||||
|
- Covers all UI elements, settings, and error messages
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ISRC Index Race Condition**: Fixed repeated index rebuilding during parallel downloads
|
||||||
|
- Added per-directory build lock using `sync.Map` and `sync.Mutex`
|
||||||
|
- Double-check locking pattern ensures index is built only once
|
||||||
|
- Significantly improves performance during CSV import with many tracks
|
||||||
|
|
||||||
|
- **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView
|
||||||
|
- Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion
|
||||||
|
- Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors
|
||||||
|
- Issue was especially noticeable during rapid queue updates (CSV import)
|
||||||
|
|
||||||
|
- **CSV Import**: Fixed CSV export not being parsed correctly
|
||||||
|
- Added support for `Artist Name(s)` header (with parentheses)
|
||||||
|
- Added support for `Track URI` header for track IDs
|
||||||
|
- Added `artists` and `track_id` as alternative header names
|
||||||
|
- Now correctly parses "Liked Songs" and playlist exports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.1.0] - 2026-01-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -105,17 +155,17 @@
|
|||||||
- YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs
|
- YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs
|
||||||
- Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter
|
- Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter
|
||||||
- `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen`
|
- `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen`
|
||||||
- `ArtistScreen` with `extensionId` skips Spotify/Deezer fetch, uses extension data only (fixes "Rate Limited" errors)
|
- `ArtistScreen` with `extensionId` skips metadata fetch, uses extension data only (fixes "Rate Limited" errors)
|
||||||
- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field
|
- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field
|
||||||
- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus
|
- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus
|
||||||
- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, Spotify Web) instead of trying to fetch from Spotify API
|
- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, etc.) instead of trying to fetch from API
|
||||||
|
|
||||||
### Extensions
|
### Extensions
|
||||||
|
|
||||||
- **YouTube Music Extension**: Updated to v1.5.0
|
- **YouTube Music Extension**: Updated to v1.5.0
|
||||||
- `getArtist()` now returns `top_tracks` array with popular songs
|
- `getArtist()` now returns `top_tracks` array with popular songs
|
||||||
- Added `header_image` and `listeners` to artist response
|
- Added `header_image` and `listeners` to artist response
|
||||||
- **Spotify Web Extension**: Updated to v1.6.0
|
- **Web Extension**: Updated to v1.6.0
|
||||||
|
|
||||||
### Localization
|
### Localization
|
||||||
|
|
||||||
@@ -148,12 +198,12 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
- One-tap install, update, and uninstall
|
- One-tap install, update, and uninstall
|
||||||
- Offline cache for browsing without internet
|
- Offline cache for browsing without internet
|
||||||
|
|
||||||
#### Spotify Web Extension
|
#### Web Extension
|
||||||
|
|
||||||
- Available in Extension Store - install and enable in Settings > Extensions
|
- Available in Extension Store - install and enable in Settings > Extensions
|
||||||
- Metadata provider using Spotify's internal web player API
|
- Metadata provider using web player API
|
||||||
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
|
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
|
||||||
- Useful when official Spotify API is rate-limited or unavailable
|
- Useful when official API is rate-limited or unavailable
|
||||||
|
|
||||||
#### Extension Capabilities
|
#### Extension Capabilities
|
||||||
|
|
||||||
@@ -188,7 +238,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
|
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
|
||||||
|
|
||||||
- Based on `album_type` from Spotify/Deezer metadata
|
- Based on `album_type` from metadata
|
||||||
- Toggle in Settings > Download > Separate Singles Folder
|
- Toggle in Settings > Download > Separate Singles Folder
|
||||||
|
|
||||||
- **Year in Album Folder Name**: New album folder structure options with release year
|
- **Year in Album Folder Name**: New album folder structure options with release year
|
||||||
@@ -226,7 +276,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
|
- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
|
||||||
|
|
||||||
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from Deezer/Spotify selector in Options
|
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from provider selector in Options
|
||||||
|
|
||||||
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
|
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
|
||||||
|
|
||||||
@@ -261,7 +311,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
||||||
|
|
||||||
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
|
- Detects existing entries by track ID, Deezer ID, or ISRC
|
||||||
|
|
||||||
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
|
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
|
||||||
|
|
||||||
@@ -330,7 +380,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
|
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
|
||||||
|
|
||||||
- `_performSearch` now checks if extension is still enabled before calling custom search
|
- `_performSearch` now checks if extension is still enabled before calling custom search
|
||||||
- Automatically falls back to Deezer/Spotify search if extension was disabled
|
- Automatically falls back to Deezer search if extension was disabled
|
||||||
- Clears `searchProvider` setting if extension no longer available
|
- Clears `searchProvider` setting if extension no longer available
|
||||||
|
|
||||||
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
|
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
|
||||||
@@ -450,7 +500,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
### Extensions
|
### Extensions
|
||||||
|
|
||||||
- **Spotify Web Extension** (example): New extension for Spotify metadata via web API
|
- **Web Extension** (example): New extension for metadata via web API
|
||||||
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
|
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
|
||||||
- Search, album, playlist, track, and artist fetching
|
- Search, album, playlist, track, and artist fetching
|
||||||
- Available in Extension Store (3.0.0-alpha.4)
|
- Available in Extension Store (3.0.0-alpha.4)
|
||||||
@@ -462,7 +512,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
|
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
|
||||||
- Based on `album_type` from Spotify/Deezer metadata
|
- Based on `album_type` from metadata
|
||||||
- Toggle in Settings > Download > Separate Singles Folder
|
- Toggle in Settings > Download > Separate Singles Folder
|
||||||
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
|
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
|
||||||
- **Browser-like Polyfills**: New global APIs for easier library porting
|
- **Browser-like Polyfills**: New global APIs for easier library porting
|
||||||
@@ -482,7 +532,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
||||||
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
|
- Detects existing entries by track ID, Deezer ID, or ISRC
|
||||||
- Replaces existing entry and moves to top of list
|
- Replaces existing entry and moves to top of list
|
||||||
- Auto-deduplicates existing history on app load
|
- Auto-deduplicates existing history on app load
|
||||||
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
|
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<img src="icon.png" width="128" />
|
<img src="icon.png" width="128" />
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -26,12 +26,12 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
## Search Source
|
## Search Source
|
||||||
|
|
||||||
SpotiFLAC supports two search sources:
|
SpotiFLAC supports multiple search sources for finding music metadata:
|
||||||
|
|
||||||
| Source | Setup |
|
| Source | Setup |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| **Deezer** (Default) | No setup required |
|
| **Deezer** (Default) | No setup required |
|
||||||
| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
|
| **Extensions** | Install additional search providers from the Store |
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ Want to create your own extension? Check out the [Extension Development Guide](h
|
|||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
@@ -60,15 +60,12 @@ A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling
|
|||||||
**Q: Why are some tracks downloading in lower quality?**
|
**Q: Why are some tracks downloading in lower quality?**
|
||||||
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
||||||
|
|
||||||
**Q: Can I download my Spotify playlists?**
|
**Q: Can I download playlists?**
|
||||||
A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||||
|
|
||||||
**Q: Why do I need to grant storage permission?**
|
**Q: Why do I need to grant storage permission?**
|
||||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
||||||
|
|
||||||
**Q: How do I download Daily Mix or Discover Weekly?**
|
|
||||||
A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API.
|
|
||||||
|
|
||||||
**Q: Is this app safe?**
|
**Q: Is this app safe?**
|
||||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||||
|
|
||||||
@@ -78,7 +75,9 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
|
|||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
|
||||||
|
|
||||||
|
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
||||||
|
|
||||||
You are solely responsible for:
|
You are solely responsible for:
|
||||||
1. Ensuring your use of this software complies with your local laws.
|
1. Ensuring your use of this software complies with your local laws.
|
||||||
|
|||||||
@@ -158,8 +158,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.fetchLyrics(spotifyId, trackName, artistName)
|
Gobackend.fetchLyrics(spotifyId, trackName, artistName, durationMs)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -168,8 +169,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -1,3 +1,19 @@
|
|||||||
files:
|
files:
|
||||||
- source: /lib/l10n/arb/app_en.arb
|
- source: /lib/l10n/arb/app_en.arb
|
||||||
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
|
translation: /lib/l10n/arb/app_%locale%.arb
|
||||||
|
languages_mapping:
|
||||||
|
locale:
|
||||||
|
# Short codes for single-variant languages
|
||||||
|
de: de
|
||||||
|
es: es
|
||||||
|
fr: fr
|
||||||
|
hi: hi
|
||||||
|
id: id
|
||||||
|
ja: ja
|
||||||
|
ko: ko
|
||||||
|
nl: nl
|
||||||
|
pt: pt
|
||||||
|
ru: ru
|
||||||
|
# Full codes for Chinese variants
|
||||||
|
zh-CN: zh_CN
|
||||||
|
zh-TW: zh_TW
|
||||||
|
|||||||
+3
-38
@@ -1,8 +1,8 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -27,10 +27,9 @@ type AmazonDownloader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Amazon downloader instance for connection reuse
|
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
amazonDownloaderOnce sync.Once
|
||||||
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
|
amazonRateLimitMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||||
@@ -55,17 +54,14 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
@@ -80,13 +76,10 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
|
||||||
// assume they're the same artist with different transliteration
|
|
||||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
foundASCII := amazonIsASCIIString(foundArtist)
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
if expectedASCII != foundASCII {
|
if expectedASCII != foundASCII {
|
||||||
@@ -127,7 +120,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Reset counter every minute
|
|
||||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||||
a.apiCallCount = 0
|
a.apiCallCount = 0
|
||||||
a.apiCallResetTime = now
|
a.apiCallResetTime = now
|
||||||
@@ -155,7 +147,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tracking
|
|
||||||
a.lastAPICallTime = time.Now()
|
a.lastAPICallTime = time.Now()
|
||||||
a.apiCallCount++
|
a.apiCallCount++
|
||||||
}
|
}
|
||||||
@@ -181,8 +172,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
|||||||
for _, region := range a.regions {
|
for _, region := range a.regions {
|
||||||
GoLog("[Amazon] Trying region: %s...\n", region)
|
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||||
|
|
||||||
// Build base URL for DoubleDouble service
|
|
||||||
// Decode base64 service URL (same as PC)
|
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||||
@@ -301,7 +290,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
|||||||
if status.Status == "done" {
|
if status.Status == "done" {
|
||||||
fmt.Println("\n[Amazon] Download ready!")
|
fmt.Println("\n[Amazon] Download ready!")
|
||||||
|
|
||||||
// Build download URL
|
|
||||||
fileURL := status.URL
|
fileURL := status.URL
|
||||||
if strings.HasPrefix(fileURL, "./") {
|
if strings.HasPrefix(fileURL, "./") {
|
||||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||||
@@ -383,7 +371,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -393,16 +380,13 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
written, err = io.Copy(pw, resp.Body)
|
written, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +394,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
// Check for any errors
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
@@ -456,24 +439,19 @@ type AmazonDownloadResult struct {
|
|||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from SongLink
|
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
var availability *TrackAvailability
|
var availability *TrackAvailability
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
|
||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
// Extract Deezer ID and use Deezer-based lookup
|
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
} else if req.SpotifyID != "" {
|
} else if req.SpotifyID != "" {
|
||||||
// Use Spotify ID
|
|
||||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
} else {
|
} else {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||||
@@ -487,7 +465,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory if needed
|
|
||||||
if req.OutputDir != "." {
|
if req.OutputDir != "." {
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
@@ -506,10 +483,8 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found
|
|
||||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -521,7 +496,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
filename = sanitizeFilename(filename) + ".flac"
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
outputPath := filepath.Join(req.OutputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
@@ -538,6 +512,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
req.TrackName,
|
req.TrackName,
|
||||||
req.ArtistName,
|
req.ArtistName,
|
||||||
req.EmbedLyrics,
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -552,8 +527,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
@@ -564,14 +537,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read existing metadata from downloaded file BEFORE embedding
|
|
||||||
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
|
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
if metaErr == nil && existingMeta != nil {
|
||||||
// Use file metadata if it has valid track/disc numbers and request doesn't have them
|
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||||
@@ -621,8 +591,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
|
|
||||||
// Read actual quality from the downloaded FLAC file
|
|
||||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
|
||||||
quality, err := GetAudioQuality(outputPath)
|
quality, err := GetAudioQuality(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
@@ -630,8 +598,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read metadata from file AFTER embedding to get accurate values
|
|
||||||
// This ensures we return what's actually in the file
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
@@ -639,7 +605,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
actualTrackNum = finalMeta.TrackNumber
|
actualTrackNum = finalMeta.TrackNumber
|
||||||
actualDiscNum = finalMeta.DiscNumber
|
actualDiscNum = finalMeta.DiscNumber
|
||||||
if finalMeta.Date != "" {
|
if finalMeta.Date != "" {
|
||||||
// Use date from file if available
|
|
||||||
req.ReleaseDate = finalMeta.Date
|
req.ReleaseDate = finalMeta.Date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ func cancelDownload(itemID string) {
|
|||||||
}
|
}
|
||||||
cancelMu.Unlock()
|
cancelMu.Unlock()
|
||||||
|
|
||||||
// Hide progress for cancelled items.
|
|
||||||
RemoveItemProgress(itemID)
|
RemoveItemProgress(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-16
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ const (
|
|||||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
|
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||||
|
|
||||||
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
||||||
// Same logic as PC version for consistency
|
// Same logic as PC version for consistency
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
@@ -32,20 +36,19 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
|
|
||||||
GoLog("[Cover] Original URL: %s", coverURL)
|
GoLog("[Cover] Original URL: %s", coverURL)
|
||||||
|
|
||||||
// First upgrade small (300) to medium (640) - always do this
|
|
||||||
downloadURL := convertSmallToMedium(coverURL)
|
downloadURL := convertSmallToMedium(coverURL)
|
||||||
if downloadURL != coverURL {
|
if downloadURL != coverURL {
|
||||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then upgrade to max quality if requested
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if maxURL != downloadURL {
|
if maxURL != downloadURL {
|
||||||
downloadURL = maxURL
|
downloadURL = maxURL
|
||||||
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
|
// Log already printed by upgradeToMaxQuality for Deezer
|
||||||
} else {
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
GoLog("[Cover] Max resolution not available, using 640x640")
|
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +56,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
|
|
||||||
// Create request with User-Agent (required by Spotify CDN)
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -74,8 +76,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate approximate resolution from file size
|
|
||||||
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
|
|
||||||
sizeKB := len(data) / 1024
|
sizeKB := len(data) / 1024
|
||||||
var resolution string
|
var resolution string
|
||||||
if sizeKB > 200 {
|
if sizeKB > 200 {
|
||||||
@@ -90,22 +90,38 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
// upgradeToMaxQuality upgrades cover URL to maximum quality
|
||||||
// Same logic as PC version - directly replaces 640x640 size code with max resolution
|
// Supports both Spotify and Deezer CDNs
|
||||||
// No HEAD verification needed - Spotify CDN always serves max resolution if available
|
|
||||||
func upgradeToMaxQuality(coverURL string) string {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify image URLs can be upgraded by changing the size parameter
|
// Spotify CDN upgrade
|
||||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
|
||||||
// ab67616d0000b273 = 640x640
|
|
||||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
|
||||||
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deezer CDN upgrade
|
||||||
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
|
return upgradeDeezerCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
|
||||||
|
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
|
||||||
|
// Available sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
|
func upgradeDeezerCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace any size pattern with 1800x1800
|
||||||
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
||||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ const (
|
|||||||
|
|
||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
// Parallel ISRC fetching settings
|
deezerMaxParallelISRC = 10
|
||||||
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeezerClient handles Deezer API interactions (no auth required)
|
// DeezerClient handles Deezer API interactions (no auth required)
|
||||||
@@ -36,7 +35,6 @@ type DeezerClient struct {
|
|||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
var (
|
var (
|
||||||
deezerClient *DeezerClient
|
deezerClient *DeezerClient
|
||||||
deezerClientOnce sync.Once
|
deezerClientOnce sync.Once
|
||||||
@@ -113,7 +111,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
albumImage = track.Album.Cover
|
albumImage = track.Album.Cover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find release date
|
|
||||||
releaseDate := track.ReleaseDate
|
releaseDate := track.ReleaseDate
|
||||||
if releaseDate == "" {
|
if releaseDate == "" {
|
||||||
releaseDate = track.Album.ReleaseDate
|
releaseDate = track.Album.ReleaseDate
|
||||||
@@ -541,7 +538,6 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we got a valid response (ID > 0)
|
|
||||||
if track.ID == 0 {
|
if track.ID == 0 {
|
||||||
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
}
|
}
|
||||||
@@ -564,7 +560,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
var resultMu sync.Mutex
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
// First, check cache for existing ISRCs
|
|
||||||
var tracksToFetch []deezerTrack
|
var tracksToFetch []deezerTrack
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
for _, track := range tracks {
|
for _, track := range tracks {
|
||||||
@@ -622,7 +617,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
// GetTrackISRC fetches ISRC for a single track (with caching)
|
// GetTrackISRC fetches ISRC for a single track (with caching)
|
||||||
// Use this when you need ISRC for download
|
// Use this when you need ISRC for download
|
||||||
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if isrc, ok := c.isrcCache[trackID]; ok {
|
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|||||||
+20
-14
@@ -18,30 +18,45 @@ type ISRCIndex struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global ISRC index cache (per output directory)
|
|
||||||
var (
|
var (
|
||||||
isrcIndexCache = make(map[string]*ISRCIndex)
|
isrcIndexCache = make(map[string]*ISRCIndex)
|
||||||
isrcIndexCacheMu sync.RWMutex
|
isrcIndexCacheMu sync.RWMutex
|
||||||
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
|
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
|
||||||
|
isrcIndexTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetISRCIndex returns or builds an ISRC index for the given directory
|
// GetISRCIndex returns or builds an ISRC index for the given directory
|
||||||
|
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
|
||||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
|
// Fast path: check cache first
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
idx, exists := isrcIndexCache[outputDir]
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
isrcIndexCacheMu.RUnlock()
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
// Return cached index if still valid
|
|
||||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build new index
|
// Slow path: need to build index
|
||||||
|
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||||
|
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||||
|
mu := buildLock.(*sync.Mutex)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check cache after acquiring lock (another goroutine may have built it)
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists = isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
return buildISRCIndex(outputDir)
|
return buildISRCIndex(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||||
// Same implementation as PC version for consistency
|
|
||||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
idx := &ISRCIndex{
|
idx := &ISRCIndex{
|
||||||
index: make(map[string]string),
|
index: make(map[string]string),
|
||||||
@@ -56,7 +71,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
fileCount := 0
|
fileCount := 0
|
||||||
|
|
||||||
// Walk directory - only check .flac files
|
|
||||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
@@ -67,13 +81,11 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read ISRC from file
|
|
||||||
metadata, err := ReadMetadata(path)
|
metadata, err := ReadMetadata(path)
|
||||||
if err != nil || metadata.ISRC == "" {
|
if err != nil || metadata.ISRC == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in index (uppercase for case-insensitive matching)
|
|
||||||
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||||
fileCount++
|
fileCount++
|
||||||
return nil
|
return nil
|
||||||
@@ -82,7 +94,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
// Cache the index
|
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
isrcIndexCache[outputDir] = idx
|
isrcIndexCache[outputDir] = idx
|
||||||
isrcIndexCacheMu.Unlock()
|
isrcIndexCacheMu.Unlock()
|
||||||
@@ -90,7 +101,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookup checks if an ISRC exists in the index (internal, returns bool)
|
|
||||||
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -193,7 +203,6 @@ type FileExistenceResult struct {
|
|||||||
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
||||||
// Same implementation as PC version for consistency
|
// Same implementation as PC version for consistency
|
||||||
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||||
// Parse input JSON
|
|
||||||
var tracks []struct {
|
var tracks []struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
TrackName string `json:"track_name"`
|
TrackName string `json:"track_name"`
|
||||||
@@ -205,10 +214,8 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
|
|
||||||
results := make([]FileExistenceResult, len(tracks))
|
results := make([]FileExistenceResult, len(tracks))
|
||||||
|
|
||||||
// Build ISRC index from output directory (scan once)
|
|
||||||
isrcIdx := GetISRCIndex(outputDir)
|
isrcIdx := GetISRCIndex(outputDir)
|
||||||
|
|
||||||
// Check each track against the index (parallel)
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -239,7 +246,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Return results as JSON
|
|
||||||
resultJSON, err := json.Marshal(results)
|
resultJSON, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to marshal results: %w", err)
|
return "", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
|||||||
+8
-55
@@ -184,7 +184,6 @@ type DownloadResponse struct {
|
|||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResult is a generic result type for all downloaders
|
|
||||||
// DownloadResult is a generic result type for all downloaders
|
// DownloadResult is a generic result type for all downloaders
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -283,10 +282,8 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
return errorResponse(err.Error())
|
return errorResponse(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
// Read actual quality from existing file
|
|
||||||
quality, qErr := GetAudioQuality(actualPath)
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -312,7 +309,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -362,7 +358,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
AddAllowedDownloadDir(req.OutputDir)
|
AddAllowedDownloadDir(req.OutputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
|
||||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
@@ -371,7 +366,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||||
|
|
||||||
// Create ordered list: preferred first, then others
|
|
||||||
services := []string{preferredService}
|
services := []string{preferredService}
|
||||||
for _, s := range allServices {
|
for _, s := range allServices {
|
||||||
if s != preferredService {
|
if s != preferredService {
|
||||||
@@ -455,10 +449,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
// Read actual quality from existing file
|
|
||||||
quality, qErr := GetAudioQuality(actualPath)
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -484,7 +476,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -539,7 +530,6 @@ func InitItemProgress(itemID string) {
|
|||||||
// FinishItemProgress marks a download item as complete and removes tracking
|
// FinishItemProgress marks a download item as complete and removes tracking
|
||||||
func FinishItemProgress(itemID string) {
|
func FinishItemProgress(itemID string) {
|
||||||
CompleteItemProgress(itemID)
|
CompleteItemProgress(itemID)
|
||||||
// Don't remove immediately - let Flutter poll one more time to see 100%
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearItemProgress removes progress tracking for a specific item
|
// ClearItemProgress removes progress tracking for a specific item
|
||||||
@@ -567,10 +557,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also get audio quality info
|
|
||||||
quality, qualityErr := GetAudioQuality(filePath)
|
quality, qualityErr := GetAudioQuality(filePath)
|
||||||
|
|
||||||
// Get duration from FLAC stream info
|
|
||||||
duration := 0
|
duration := 0
|
||||||
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
@@ -589,7 +577,6 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
"duration": duration,
|
"duration": duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add quality info if available
|
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
result["bit_depth"] = quality.BitDepth
|
result["bit_depth"] = quality.BitDepth
|
||||||
result["sample_rate"] = quality.SampleRate
|
result["sample_rate"] = quality.SampleRate
|
||||||
@@ -640,7 +627,6 @@ func PreBuildDuplicateIndex(outputDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
||||||
// Call this when files are deleted or moved
|
|
||||||
func InvalidateDuplicateIndex(outputDir string) {
|
func InvalidateDuplicateIndex(outputDir string) {
|
||||||
InvalidateISRCCache(outputDir)
|
InvalidateISRCCache(outputDir)
|
||||||
}
|
}
|
||||||
@@ -663,9 +649,11 @@ func SanitizeFilename(filename string) string {
|
|||||||
|
|
||||||
// FetchLyrics fetches lyrics for a track from LRCLIB
|
// FetchLyrics fetches lyrics for a track from LRCLIB
|
||||||
// Returns JSON with lyrics data
|
// Returns JSON with lyrics data
|
||||||
func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
|
||||||
|
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -687,8 +675,8 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|||||||
|
|
||||||
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
||||||
// First tries to extract from file, then falls back to fetching from internet
|
// First tries to extract from file, then falls back to fetching from internet
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
|
||||||
// Try to extract from file first (much faster)
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||||
if filePath != "" {
|
if filePath != "" {
|
||||||
lyrics, err := ExtractLyrics(filePath)
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
if err == nil && lyrics != "" {
|
if err == nil && lyrics != "" {
|
||||||
@@ -696,14 +684,13 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to fetching from internet
|
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to LRC format with metadata headers (like PC version)
|
|
||||||
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
@@ -740,7 +727,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
|||||||
return errorResponse("Invalid JSON: " + err.Error())
|
return errorResponse("Invalid JSON: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to PreWarmCacheRequest
|
|
||||||
requests := make([]PreWarmCacheRequest, len(tracks))
|
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||||
for i, t := range tracks {
|
for i, t := range tracks {
|
||||||
requests[i] = PreWarmCacheRequest{
|
requests[i] = PreWarmCacheRequest{
|
||||||
@@ -752,7 +738,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run in background
|
|
||||||
go PreWarmTrackCache(requests)
|
go PreWarmTrackCache(requests)
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
@@ -872,7 +857,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
||||||
// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer
|
|
||||||
// Useful when Spotify API is rate limited
|
// Useful when Spotify API is rate limited
|
||||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
@@ -881,14 +865,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
|
|
||||||
// For tracks, we can use SongLink to get Deezer ID
|
|
||||||
if resourceType == "track" {
|
if resourceType == "track" {
|
||||||
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch metadata from Deezer
|
|
||||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||||
@@ -902,14 +884,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For albums, SongLink also provides mapping
|
|
||||||
if resourceType == "album" {
|
if resourceType == "album" {
|
||||||
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch album metadata from Deezer
|
|
||||||
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||||
@@ -932,10 +912,8 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Try Spotify first
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No Spotify credentials - fall through to Deezer fallback
|
|
||||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||||
} else {
|
} else {
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
@@ -947,15 +925,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a rate limit error
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||||
// Not a rate limit error, return original error
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limited - try Deezer fallback for tracks and albums
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
||||||
@@ -964,11 +939,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
// Convert to Deezer
|
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist and playlist not supported for fallback
|
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1006,6 @@ func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
// Determine error type based on message
|
|
||||||
errorType := "unknown"
|
errorType := "unknown"
|
||||||
lowerMsg := strings.ToLower(msg)
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
@@ -1122,7 +1094,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize with saved settings
|
|
||||||
settingsStore := GetExtensionSettingsStore()
|
settingsStore := GetExtensionSettingsStore()
|
||||||
settings := settingsStore.GetAll(ext.ID)
|
settings := settingsStore.GetAll(ext.ID)
|
||||||
if len(settings) > 0 {
|
if len(settings) > 0 {
|
||||||
@@ -1273,7 +1244,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-initialize extension with new settings
|
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
return manager.InitializeExtension(extensionID, settings)
|
return manager.InitializeExtension(extensionID, settings)
|
||||||
}
|
}
|
||||||
@@ -1372,7 +1342,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is expired
|
|
||||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1469,7 +1438,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !ext.Manifest.IsMetadataProvider() {
|
if !ext.Manifest.IsMetadataProvider() {
|
||||||
// Not a metadata provider, return original
|
|
||||||
return trackJSON, nil
|
return trackJSON, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1481,7 +1449,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
|||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
enrichedTrack, err := provider.EnrichTrack(&track)
|
enrichedTrack, err := provider.EnrichTrack(&track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Error enriching, return original
|
|
||||||
return trackJSON, nil
|
return trackJSON, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1518,7 +1485,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to map format for Flutter, ensuring images field is set
|
|
||||||
result := make([]map[string]interface{}, len(tracks))
|
result := make([]map[string]interface{}, len(tracks))
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
result[i] = map[string]interface{}{
|
result[i] = map[string]interface{}{
|
||||||
@@ -1585,12 +1551,10 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
result := resultWithID.Result
|
result := resultWithID.Result
|
||||||
extensionID := resultWithID.ExtensionID
|
extensionID := resultWithID.ExtensionID
|
||||||
|
|
||||||
// Check if result is nil (handler found but returned error)
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
|
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"type": result.Type,
|
"type": result.Type,
|
||||||
"extension_id": extensionID,
|
"extension_id": extensionID,
|
||||||
@@ -1598,7 +1562,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"cover_url": result.CoverURL,
|
"cover_url": result.CoverURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add track if single track
|
|
||||||
if result.Track != nil {
|
if result.Track != nil {
|
||||||
response["track"] = map[string]interface{}{
|
response["track"] = map[string]interface{}{
|
||||||
"id": result.Track.ID,
|
"id": result.Track.ID,
|
||||||
@@ -1616,7 +1579,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tracks if multiple
|
|
||||||
if len(result.Tracks) > 0 {
|
if len(result.Tracks) > 0 {
|
||||||
tracks := make([]map[string]interface{}, len(result.Tracks))
|
tracks := make([]map[string]interface{}, len(result.Tracks))
|
||||||
for i, track := range result.Tracks {
|
for i, track := range result.Tracks {
|
||||||
@@ -1654,7 +1616,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add artist info if present
|
|
||||||
if result.Artist != nil {
|
if result.Artist != nil {
|
||||||
artistResponse := map[string]interface{}{
|
artistResponse := map[string]interface{}{
|
||||||
"id": result.Artist.ID,
|
"id": result.Artist.ID,
|
||||||
@@ -1665,7 +1626,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"provider_id": result.Artist.ProviderID,
|
"provider_id": result.Artist.ProviderID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add albums if present
|
|
||||||
if len(result.Artist.Albums) > 0 {
|
if len(result.Artist.Albums) > 0 {
|
||||||
albums := make([]map[string]interface{}, len(result.Artist.Albums))
|
albums := make([]map[string]interface{}, len(result.Artist.Albums))
|
||||||
for i, album := range result.Artist.Albums {
|
for i, album := range result.Artist.Albums {
|
||||||
@@ -1688,7 +1648,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
artistResponse["albums"] = albums
|
artistResponse["albums"] = albums
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add top tracks if present
|
|
||||||
if len(result.Artist.TopTracks) > 0 {
|
if len(result.Artist.TopTracks) > 0 {
|
||||||
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
||||||
for i, track := range result.Artist.TopTracks {
|
for i, track := range result.Artist.TopTracks {
|
||||||
@@ -1758,10 +1717,8 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
return "", fmt.Errorf("album not found")
|
return "", fmt.Errorf("album not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert tracks to map format
|
|
||||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||||
for i, track := range album.Tracks {
|
for i, track := range album.Tracks {
|
||||||
// Use album cover as fallback if track doesn't have its own cover
|
|
||||||
trackCover := track.ResolvedCoverURL()
|
trackCover := track.ResolvedCoverURL()
|
||||||
if trackCover == "" {
|
if trackCover == "" {
|
||||||
trackCover = album.CoverURL
|
trackCover = album.CoverURL
|
||||||
@@ -1818,7 +1775,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
// Try getPlaylist first, fall back to getAlbum (some extensions use album for playlists)
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
|
||||||
@@ -1856,10 +1812,8 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
album.Tracks[i].ProviderID = ext.ID
|
album.Tracks[i].ProviderID = ext.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert tracks to map format
|
|
||||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||||
for i, track := range album.Tracks {
|
for i, track := range album.Tracks {
|
||||||
// Use playlist cover as fallback if track doesn't have its own cover
|
|
||||||
trackCover := track.ResolvedCoverURL()
|
trackCover := track.ResolvedCoverURL()
|
||||||
if trackCover == "" {
|
if trackCover == "" {
|
||||||
trackCover = album.CoverURL
|
trackCover = album.CoverURL
|
||||||
@@ -1922,7 +1876,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
return "", fmt.Errorf("artist not found")
|
return "", fmt.Errorf("artist not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert albums to map format
|
|
||||||
albums := make([]map[string]interface{}, len(artist.Albums))
|
albums := make([]map[string]interface{}, len(artist.Albums))
|
||||||
for i, album := range artist.Albums {
|
for i, album := range artist.Albums {
|
||||||
albums[i] = map[string]interface{}{
|
albums[i] = map[string]interface{}{
|
||||||
|
|||||||
@@ -18,11 +18,9 @@ import (
|
|||||||
// compareVersions compares two semantic version strings
|
// compareVersions compares two semantic version strings
|
||||||
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||||
func compareVersions(v1, v2 string) int {
|
func compareVersions(v1, v2 string) int {
|
||||||
// Parse version parts
|
|
||||||
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||||
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||||
|
|
||||||
// Pad shorter version with zeros
|
|
||||||
maxLen := len(parts1)
|
maxLen := len(parts1)
|
||||||
if len(parts2) > maxLen {
|
if len(parts2) > maxLen {
|
||||||
maxLen = len(parts2)
|
maxLen = len(parts2)
|
||||||
@@ -52,12 +50,12 @@ func compareVersions(v1, v2 string) int {
|
|||||||
type LoadedExtension struct {
|
type LoadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized)
|
VM *goja.Runtime `json:"-"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
DataDir string `json:"data_dir"` // Extension's data directory
|
DataDir string `json:"data_dir"`
|
||||||
SourceDir string `json:"source_dir"` // Where extension files are extracted
|
SourceDir string `json:"source_dir"`
|
||||||
IconPath string `json:"icon_path"` // Full path to icon file (if exists)
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionManager manages all loaded extensions
|
// ExtensionManager manages all loaded extensions
|
||||||
@@ -68,7 +66,6 @@ type ExtensionManager struct {
|
|||||||
dataDir string // Base directory for extension data
|
dataDir string // Base directory for extension data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global extension manager instance
|
|
||||||
var (
|
var (
|
||||||
globalExtManager *ExtensionManager
|
globalExtManager *ExtensionManager
|
||||||
globalExtManagerOnce sync.Once
|
globalExtManagerOnce sync.Once
|
||||||
@@ -92,7 +89,6 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
m.extensionsDir = extensionsDir
|
m.extensionsDir = extensionsDir
|
||||||
m.dataDir = dataDir
|
m.dataDir = dataDir
|
||||||
|
|
||||||
// Create directories if they don't exist
|
|
||||||
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
|
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create extensions directory: %w", err)
|
return fmt.Errorf("failed to create extensions directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -117,7 +113,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
// Find and read manifest.json
|
|
||||||
var manifestData []byte
|
var manifestData []byte
|
||||||
var hasIndexJS bool
|
var hasIndexJS bool
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
@@ -146,13 +141,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension already loaded - if so, try upgrade (check without holding lock for long)
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existing, exists := m.extensions[manifest.Name]
|
existing, exists := m.extensions[manifest.Name]
|
||||||
var existingVersion string
|
var existingVersion string
|
||||||
@@ -164,7 +157,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
// Check if this is an upgrade
|
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
// This is an upgrade - call UpgradeExtension
|
// This is an upgrade - call UpgradeExtension
|
||||||
@@ -176,16 +168,13 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now acquire write lock for the rest of the operation
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// Double-check extension wasn't added while we were waiting for lock
|
|
||||||
if _, exists := m.extensions[manifest.Name]; exists {
|
if _, exists := m.extensions[manifest.Name]; exists {
|
||||||
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extension directory
|
|
||||||
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||||
@@ -206,19 +195,16 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
destPath := filepath.Join(extDir, relPath)
|
destPath := filepath.Join(extDir, relPath)
|
||||||
|
|
||||||
// Create parent directories if needed
|
|
||||||
destDir := filepath.Dir(destPath)
|
destDir := filepath.Dir(destPath)
|
||||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination file
|
|
||||||
destFile, err := os.Create(destPath)
|
destFile, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy content
|
|
||||||
srcFile, err := file.Open()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
destFile.Close()
|
destFile.Close()
|
||||||
@@ -233,13 +219,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directory for extension
|
|
||||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create loaded extension
|
|
||||||
ext := &LoadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
@@ -263,23 +247,19 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
|
|
||||||
// initializeVM creates and initializes the Goja VM for an extension
|
// initializeVM creates and initializes the Goja VM for an extension
|
||||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||||
// Create new Goja runtime
|
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
|
|
||||||
// Read index.js
|
|
||||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||||
jsCode, err := os.ReadFile(indexPath)
|
jsCode, err := os.ReadFile(indexPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extension runtime and register sandboxed APIs
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
|
|
||||||
// Set up console.log for debugging
|
|
||||||
console := vm.NewObject()
|
console := vm.NewObject()
|
||||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||||
args := make([]interface{}, len(call.Arguments))
|
args := make([]interface{}, len(call.Arguments))
|
||||||
@@ -291,12 +271,10 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
})
|
})
|
||||||
vm.Set("console", console)
|
vm.Set("console", console)
|
||||||
|
|
||||||
// Set up registerExtension function
|
|
||||||
var registeredExtension goja.Value
|
var registeredExtension goja.Value
|
||||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) > 0 {
|
if len(call.Arguments) > 0 {
|
||||||
registeredExtension = call.Arguments[0]
|
registeredExtension = call.Arguments[0]
|
||||||
// Also set it as global 'extension' variable for later access
|
|
||||||
vm.Set("extension", call.Arguments[0])
|
vm.Set("extension", call.Arguments[0])
|
||||||
}
|
}
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
@@ -406,7 +384,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
// Check if it's an extracted extension directory
|
|
||||||
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
|
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
|
||||||
if _, err := os.Stat(manifestPath); err == nil {
|
if _, err := os.Stat(manifestPath); err == nil {
|
||||||
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
|
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
|
||||||
@@ -418,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||||
// Load from package file
|
|
||||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||||
@@ -437,7 +413,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// Read manifest
|
|
||||||
manifestPath := filepath.Join(dirPath, "manifest.json")
|
manifestPath := filepath.Join(dirPath, "manifest.json")
|
||||||
manifestData, err := os.ReadFile(manifestPath)
|
manifestData, err := os.ReadFile(manifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -450,25 +425,21 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if index.js exists
|
|
||||||
indexPath := filepath.Join(dirPath, "index.js")
|
indexPath := filepath.Join(dirPath, "index.js")
|
||||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("Extension is missing index.js file")
|
return nil, fmt.Errorf("Extension is missing index.js file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension already loaded - skip silently (for directory loading on startup)
|
|
||||||
if existing, exists := m.extensions[manifest.Name]; exists {
|
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||||
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directory for extension
|
|
||||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create loaded extension
|
|
||||||
ext := &LoadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
@@ -541,7 +512,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
// Find and read manifest.json
|
|
||||||
var manifestData []byte
|
var manifestData []byte
|
||||||
var hasIndexJS bool
|
var hasIndexJS bool
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
@@ -570,13 +540,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension exists
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existing, exists := m.extensions[newManifest.Name]
|
existing, exists := m.extensions[newManifest.Name]
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
@@ -612,19 +580,15 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate extension directory
|
|
||||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract all files from new package (preserving directory structure)
|
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve relative path within the zip (support subdirectories)
|
|
||||||
// Clean the path to prevent path traversal attacks
|
|
||||||
relPath := filepath.Clean(file.Name)
|
relPath := filepath.Clean(file.Name)
|
||||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||||
@@ -632,19 +596,16 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
destPath := filepath.Join(extDir, relPath)
|
destPath := filepath.Join(extDir, relPath)
|
||||||
|
|
||||||
// Create parent directories if needed
|
|
||||||
destDir := filepath.Dir(destPath)
|
destDir := filepath.Dir(destPath)
|
||||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination file
|
|
||||||
destFile, err := os.Create(destPath)
|
destFile, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy content
|
|
||||||
srcFile, err := file.Open()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
destFile.Close()
|
destFile.Close()
|
||||||
@@ -659,7 +620,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new loaded extension (reusing data directory, preserving enabled state)
|
|
||||||
ext := &LoadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: newManifest.Name,
|
ID: newManifest.Name,
|
||||||
Manifest: newManifest,
|
Manifest: newManifest,
|
||||||
@@ -708,7 +668,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
// Find and read manifest.json
|
|
||||||
var manifestData []byte
|
var manifestData []byte
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
name := filepath.Base(file.Name)
|
name := filepath.Base(file.Name)
|
||||||
@@ -730,13 +689,11 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
return nil, fmt.Errorf("manifest.json not found")
|
return nil, fmt.Errorf("manifest.json not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse manifest
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension exists
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existing, exists := m.extensions[newManifest.Name]
|
existing, exists := m.extensions[newManifest.Name]
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
@@ -752,7 +709,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
info.CurrentVersion = ""
|
info.CurrentVersion = ""
|
||||||
info.CanUpgrade = false
|
info.CanUpgrade = false
|
||||||
} else {
|
} else {
|
||||||
// Compare versions
|
|
||||||
info.CurrentVersion = existing.Manifest.Version
|
info.CurrentVersion = existing.Manifest.Version
|
||||||
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
|
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
|
||||||
}
|
}
|
||||||
@@ -805,7 +761,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
|
|
||||||
infos := make([]ExtensionInfo, len(extensions))
|
infos := make([]ExtensionInfo, len(extensions))
|
||||||
for i, ext := range extensions {
|
for i, ext := range extensions {
|
||||||
// Build permissions list
|
|
||||||
permissions := []string{}
|
permissions := []string{}
|
||||||
for _, domain := range ext.Manifest.Permissions.Network {
|
for _, domain := range ext.Manifest.Permissions.Network {
|
||||||
permissions = append(permissions, "network:"+domain)
|
permissions = append(permissions, "network:"+domain)
|
||||||
@@ -822,7 +777,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
status = "disabled"
|
status = "disabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for icon file
|
|
||||||
iconPath := ""
|
iconPath := ""
|
||||||
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
||||||
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
||||||
@@ -830,7 +784,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
iconPath = possibleIcon
|
iconPath = possibleIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: check for icon.png if not specified in manifest
|
|
||||||
if iconPath == "" && ext.SourceDir != "" {
|
if iconPath == "" && ext.SourceDir != "" {
|
||||||
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
||||||
if _, err := os.Stat(possibleIcon); err == nil {
|
if _, err := os.Stat(possibleIcon); err == nil {
|
||||||
@@ -887,13 +840,11 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert settings to JSON for passing to JS
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
settingsJSON, err := json.Marshal(settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to save settings")
|
return fmt.Errorf("Failed to save settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call initialize function
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
var settings = %s;
|
var settings = %s;
|
||||||
@@ -917,7 +868,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check result
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
exported := result.Export()
|
exported := result.Export()
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
@@ -973,7 +923,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check result
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
exported := result.Export()
|
exported := result.Export()
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set provider ID on all tracks
|
|
||||||
for i := range searchResult.Tracks {
|
for i := range searchResult.Tracks {
|
||||||
searchResult.Tracks[i].ProviderID = p.extension.ID
|
searchResult.Tracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -737,12 +736,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
||||||
if err == nil && enrichedTrack != nil {
|
if err == nil && enrichedTrack != nil {
|
||||||
// Update request with enriched data
|
|
||||||
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
|
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
|
||||||
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
||||||
req.ISRC = enrichedTrack.ISRC
|
req.ISRC = enrichedTrack.ISRC
|
||||||
}
|
}
|
||||||
// Update service-specific IDs from Odesli enrichment
|
|
||||||
if enrichedTrack.TidalID != "" {
|
if enrichedTrack.TidalID != "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
|
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
|
||||||
req.TidalID = enrichedTrack.TidalID
|
req.TidalID = enrichedTrack.TidalID
|
||||||
@@ -755,7 +752,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
|
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
|
||||||
req.DeezerID = enrichedTrack.DeezerID
|
req.DeezerID = enrichedTrack.DeezerID
|
||||||
}
|
}
|
||||||
// Can also update other fields if needed
|
|
||||||
if enrichedTrack.Name != "" {
|
if enrichedTrack.Name != "" {
|
||||||
req.TrackName = enrichedTrack.Name
|
req.TrackName = enrichedTrack.Name
|
||||||
}
|
}
|
||||||
@@ -772,7 +768,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
ext, err := extManager.GetExtension(req.Source)
|
ext, err := extManager.GetExtension(req.Source)
|
||||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||||
// Check if this extension wants to skip built-in fallback
|
|
||||||
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
@@ -783,7 +778,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||||
|
|
||||||
// Build output path
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPath(req)
|
||||||
|
|
||||||
// Download directly using the track ID from the extension
|
// Download directly using the track ID from the extension
|
||||||
@@ -916,7 +910,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
// Check availability first
|
|
||||||
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
|
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
|
||||||
if err != nil || !availability.Available {
|
if err != nil || !availability.Available {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||||
@@ -926,12 +919,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build output path
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPath(req)
|
||||||
|
|
||||||
// Download
|
|
||||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
||||||
// Update progress
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||||
}
|
}
|
||||||
@@ -1171,7 +1161,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
tracks = []ExtTrackMetadata{}
|
tracks = []ExtTrackMetadata{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set provider ID on all tracks
|
|
||||||
for i := range tracks {
|
for i := range tracks {
|
||||||
tracks[i].ProviderID = p.extension.ID
|
tracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -1255,7 +1244,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Set provider ID on top tracks
|
|
||||||
for i := range handleResult.Artist.TopTracks {
|
for i := range handleResult.Artist.TopTracks {
|
||||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -1493,12 +1481,10 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
|||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
// Check if hook is enabled (TODO: check user settings)
|
|
||||||
if !hook.DefaultEnabled {
|
if !hook.DefaultEnabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if format is supported
|
|
||||||
ext := strings.ToLower(filepath.Ext(currentPath))
|
ext := strings.ToLower(filepath.Ext(currentPath))
|
||||||
if len(hook.SupportedFormats) > 0 {
|
if len(hook.SupportedFormats) > 0 {
|
||||||
supported := false
|
supported := false
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default timeout for JS execution (30 seconds)
|
|
||||||
const DefaultJSTimeout = 30 * time.Second
|
const DefaultJSTimeout = 30 * time.Second
|
||||||
|
|
||||||
// Global auth state for extensions (stores pending auth codes)
|
|
||||||
var (
|
var (
|
||||||
extensionAuthState = make(map[string]*ExtensionAuthState)
|
extensionAuthState = make(map[string]*ExtensionAuthState)
|
||||||
extensionAuthStateMu sync.RWMutex
|
extensionAuthStateMu sync.RWMutex
|
||||||
@@ -39,7 +37,6 @@ type PendingAuthRequest struct {
|
|||||||
CallbackURL string
|
CallbackURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global pending auth requests (Flutter polls this)
|
|
||||||
var (
|
var (
|
||||||
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
||||||
pendingAuthRequestsMu sync.RWMutex
|
pendingAuthRequestsMu sync.RWMutex
|
||||||
@@ -52,7 +49,6 @@ func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
|||||||
return pendingAuthRequests[extensionID]
|
return pendingAuthRequests[extensionID]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
|
|
||||||
func ClearPendingAuthRequest(extensionID string) {
|
func ClearPendingAuthRequest(extensionID string) {
|
||||||
pendingAuthRequestsMu.Lock()
|
pendingAuthRequestsMu.Lock()
|
||||||
defer pendingAuthRequestsMu.Unlock()
|
defer pendingAuthRequestsMu.Unlock()
|
||||||
@@ -101,7 +97,6 @@ type ExtensionRuntime struct {
|
|||||||
|
|
||||||
// NewExtensionRuntime creates a new runtime for an extension
|
// NewExtensionRuntime creates a new runtime for an extension
|
||||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||||
// Create a cookie jar for this extension
|
|
||||||
jar, _ := newSimpleCookieJar()
|
jar, _ := newSimpleCookieJar()
|
||||||
|
|
||||||
runtime := &ExtensionRuntime{
|
runtime := &ExtensionRuntime{
|
||||||
|
|||||||
@@ -11,23 +11,18 @@ var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
|||||||
|
|
||||||
// sanitizeFilename removes invalid characters from filename
|
// sanitizeFilename removes invalid characters from filename
|
||||||
func sanitizeFilename(filename string) string {
|
func sanitizeFilename(filename string) string {
|
||||||
// Replace invalid characters with underscore
|
|
||||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||||
|
|
||||||
// Remove leading/trailing spaces and dots
|
|
||||||
sanitized = strings.TrimSpace(sanitized)
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
sanitized = strings.Trim(sanitized, ".")
|
sanitized = strings.Trim(sanitized, ".")
|
||||||
|
|
||||||
// Collapse multiple underscores
|
|
||||||
multiUnderscore := regexp.MustCompile(`_+`)
|
multiUnderscore := regexp.MustCompile(`_+`)
|
||||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||||
|
|
||||||
// Limit length (Android has 255 byte limit for filenames)
|
|
||||||
if len(sanitized) > 200 {
|
if len(sanitized) > 200 {
|
||||||
sanitized = sanitized[:200]
|
sanitized = sanitized[:200]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure not empty
|
|
||||||
if sanitized == "" {
|
if sanitized == "" {
|
||||||
sanitized = "untitled"
|
sanitized = "untitled"
|
||||||
}
|
}
|
||||||
@@ -43,7 +38,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
|
|
||||||
result := template
|
result := template
|
||||||
|
|
||||||
// Replace placeholders
|
|
||||||
placeholders := map[string]string{
|
placeholders := map[string]string{
|
||||||
"{title}": getString(metadata, "title"),
|
"{title}": getString(metadata, "title"),
|
||||||
"{artist}": getString(metadata, "artist"),
|
"{artist}": getString(metadata, "artist"),
|
||||||
@@ -63,7 +57,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
func getString(m map[string]interface{}, key string) string {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if v, ok := m[key]; ok {
|
if v, ok := m[key]; ok {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
// Trim leading/trailing whitespace to prevent filename issues
|
|
||||||
return strings.TrimSpace(s)
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-26
@@ -20,13 +20,11 @@ import (
|
|||||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||||
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
||||||
func getRandomUserAgent() string {
|
func getRandomUserAgent() string {
|
||||||
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
winMajor := rand.Intn(2) + 10
|
||||||
// Some APIs may block mobile User-Agents, so we use desktop format
|
|
||||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
|
||||||
|
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
chromeVersion := rand.Intn(25) + 100
|
||||||
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
chromeBuild := rand.Intn(1500) + 3000
|
||||||
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
chromePatch := rand.Intn(65) + 60
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||||
@@ -39,7 +37,6 @@ func getRandomUserAgent() string {
|
|||||||
|
|
||||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||||
// Kept for potential future use
|
|
||||||
// func getRandomMacUserAgent() string {
|
// func getRandomMacUserAgent() string {
|
||||||
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||||
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||||
@@ -66,7 +63,6 @@ func getRandomUserAgent() string {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||||
// Kept for potential future use
|
|
||||||
// func getRandomDesktopUserAgent() string {
|
// func getRandomDesktopUserAgent() string {
|
||||||
// if rand.Intn(2) == 0 {
|
// if rand.Intn(2) == 0 {
|
||||||
// return getRandomUserAgent() // Windows
|
// return getRandomUserAgent() // Windows
|
||||||
@@ -74,17 +70,15 @@ func getRandomUserAgent() string {
|
|||||||
// return getRandomMacUserAgent() // Mac
|
// return getRandomMacUserAgent() // Mac
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Default timeout values
|
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
DefaultTimeout = 60 * time.Second
|
||||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
DownloadTimeout = 120 * time.Second
|
||||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3 // Default retry count
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
DefaultRetryDelay = 1 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
// Optimized for large file downloads (FLAC ~30-50MB)
|
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -96,27 +90,24 @@ var sharedTransport = &http.Transport{
|
|||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
DisableKeepAlives: false,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
WriteBufferSize: 64 * 1024,
|
||||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
ReadBufferSize: 64 * 1024,
|
||||||
DisableCompression: true, // FLAC is already compressed
|
DisableCompression: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for general requests (reuses connections)
|
|
||||||
var sharedClient = &http.Client{
|
var sharedClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DefaultTimeout,
|
Timeout: DefaultTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for downloads (longer timeout, reuses connections)
|
|
||||||
var downloadClient = &http.Client{
|
var downloadClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DownloadTimeout,
|
Timeout: DownloadTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||||
// Uses shared transport for connection reuse
|
|
||||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
@@ -124,18 +115,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSharedClient returns the shared HTTP client for general requests
|
|
||||||
func GetSharedClient() *http.Client {
|
func GetSharedClient() *http.Client {
|
||||||
return sharedClient
|
return sharedClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadClient returns the shared HTTP client for downloads
|
|
||||||
func GetDownloadClient() *http.Client {
|
func GetDownloadClient() *http.Client {
|
||||||
return downloadClient
|
return downloadClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseIdleConnections closes idle connections in the shared transport
|
// CloseIdleConnections closes idle connections in the shared transport
|
||||||
// Call this periodically during large batch downloads to prevent connection buildup
|
|
||||||
func CloseIdleConnections() {
|
func CloseIdleConnections() {
|
||||||
sharedTransport.CloseIdleConnections()
|
sharedTransport.CloseIdleConnections()
|
||||||
}
|
}
|
||||||
@@ -146,7 +134,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
|
|||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check for ISP blocking
|
|
||||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
}
|
}
|
||||||
return resp, err
|
return resp, err
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type LogBuffer struct {
|
|||||||
entries []LogEntry
|
entries []LogEntry
|
||||||
maxSize int
|
maxSize int
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
|
loggingEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -60,7 +60,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
|
|
||||||
// Skip if logging is disabled (except for errors which are always logged)
|
|
||||||
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -73,12 +72,10 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(lb.entries) >= lb.maxSize {
|
if len(lb.entries) >= lb.maxSize {
|
||||||
// Remove oldest entry
|
|
||||||
lb.entries = lb.entries[1:]
|
lb.entries = lb.entries[1:]
|
||||||
}
|
}
|
||||||
lb.entries = append(lb.entries, entry)
|
lb.entries = append(lb.entries, entry)
|
||||||
|
|
||||||
// Also print to logcat for debugging
|
|
||||||
fmt.Printf("[%s] %s\n", tag, message)
|
fmt.Printf("[%s] %s\n", tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +88,6 @@ func (lb *LogBuffer) GetAll() string {
|
|||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSince returns log entries since the given index (internal use)
|
|
||||||
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
|
|||||||
+155
-9
@@ -3,14 +3,100 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Lyrics Cache with TTL
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours
|
||||||
|
durationToleranceSec = 10.0 // Duration matching tolerance in seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
type lyricsCacheEntry struct {
|
||||||
|
response *LyricsResponse
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]*lyricsCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalLyricsCache = &lyricsCache{
|
||||||
|
cache: make(map[string]*lyricsCacheEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
|
||||||
|
// Normalize key: lowercase, trim spaces
|
||||||
|
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
|
||||||
|
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
|
||||||
|
// Round duration to nearest 10 seconds for cache key
|
||||||
|
roundedDuration := math.Round(durationSec/10) * 10
|
||||||
|
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
key := c.generateKey(artist, track, durationSec)
|
||||||
|
entry, exists := c.cache[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if time.Now().After(entry.expiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.response, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
key := c.generateKey(artist, track, durationSec)
|
||||||
|
c.cache[key] = &lyricsCacheEntry{
|
||||||
|
response: response,
|
||||||
|
expiresAt: time.Now().Add(lyricsCacheTTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanExpired removes expired entries from cache
|
||||||
|
func (c *lyricsCache) CleanExpired() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cleaned := 0
|
||||||
|
for key, entry := range c.cache {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(c.cache, key)
|
||||||
|
cleaned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns current cache size
|
||||||
|
func (c *lyricsCache) Size() int {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return len(c.cache)
|
||||||
|
}
|
||||||
|
|
||||||
type LRCLibResponse struct {
|
type LRCLibResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -86,7 +172,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
|||||||
return c.parseLRCLibResponse(&lrcResp), nil
|
return c.parseLRCLibResponse(&lrcResp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
|
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
|
||||||
|
// durationSec: track duration in seconds, use 0 to skip duration matching
|
||||||
|
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
|
||||||
baseURL := "https://lrclib.net/api/search"
|
baseURL := "https://lrclib.net/api/search"
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("q", query)
|
params.Set("q", query)
|
||||||
@@ -118,6 +206,13 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
|||||||
return nil, fmt.Errorf("no lyrics found")
|
return nil, fmt.Errorf("no lyrics found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter and score results based on duration matching and synced lyrics
|
||||||
|
bestMatch := c.findBestMatch(results, durationSec)
|
||||||
|
if bestMatch != nil {
|
||||||
|
return c.parseLRCLibResponse(bestMatch), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return first result with synced lyrics
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
if result.SyncedLyrics != "" {
|
if result.SyncedLyrics != "" {
|
||||||
return c.parseLRCLibResponse(&result), nil
|
return c.parseLRCLibResponse(&result), nil
|
||||||
@@ -127,38 +222,89 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
|||||||
return c.parseLRCLibResponse(&results[0]), nil
|
return c.parseLRCLibResponse(&results[0]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
|
// findBestMatch finds the best matching lyrics based on duration and sync status
|
||||||
// Strategy 1: Direct match with artist and track name
|
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||||
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
|
var bestSynced *LRCLibResponse
|
||||||
|
var bestPlain *LRCLibResponse
|
||||||
|
|
||||||
|
for i := range results {
|
||||||
|
result := &results[i]
|
||||||
|
|
||||||
|
// Check duration match if target duration is provided
|
||||||
|
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
|
||||||
|
|
||||||
|
if durationMatches {
|
||||||
|
// Prefer synced lyrics over plain
|
||||||
|
if result.SyncedLyrics != "" && bestSynced == nil {
|
||||||
|
bestSynced = result
|
||||||
|
} else if result.PlainLyrics != "" && bestPlain == nil {
|
||||||
|
bestPlain = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return synced first, then plain
|
||||||
|
if bestSynced != nil {
|
||||||
|
return bestSynced
|
||||||
|
}
|
||||||
|
return bestPlain
|
||||||
|
}
|
||||||
|
|
||||||
|
// durationMatches checks if two durations are within tolerance
|
||||||
|
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||||
|
diff := math.Abs(lrcDuration - targetDuration)
|
||||||
|
return diff <= durationToleranceSec
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
|
||||||
|
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
||||||
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
|
// Check cache first
|
||||||
|
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||||
|
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||||
|
cachedCopy := *cached
|
||||||
|
cachedCopy.Source = cached.Source + " (cached)"
|
||||||
|
return &cachedCopy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyrics *LyricsResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Try exact match first
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Try with simplified track name
|
// Try with simplified track name
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
lyrics.Source = "LRCLIB (simplified)"
|
lyrics.Source = "LRCLIB (simplified)"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Search with full query
|
// Search with duration matching
|
||||||
query := artistName + " " + trackName
|
query := artistName + " " + trackName
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
lyrics.Source = "LRCLIB Search"
|
lyrics.Source = "LRCLIB Search"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 4: Search with simplified query
|
// Search with simplified name and duration matching
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = artistName + " " + simplifiedTrack
|
query = artistName + " " + simplifiedTrack
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
lyrics.Source = "LRCLIB Search (simplified)"
|
lyrics.Source = "LRCLIB Search (simplified)"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-75
@@ -33,7 +33,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create vorbis comment block
|
|
||||||
var cmtIdx int = -1
|
var cmtIdx int = -1
|
||||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
@@ -52,7 +51,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
cmt = flacvorbis.New()
|
cmt = flacvorbis.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set metadata fields
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setComment(cmt, "ARTIST", metadata.Artist)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
@@ -84,7 +82,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or add vorbis comment block
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -92,14 +89,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
f.Meta = append(f.Meta, &cmtBlock)
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cover art if provided
|
|
||||||
if coverPath != "" {
|
if coverPath != "" {
|
||||||
if fileExists(coverPath) {
|
if fileExists(coverPath) {
|
||||||
coverData, err := os.ReadFile(coverPath)
|
coverData, err := os.ReadFile(coverPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
||||||
} else {
|
} else {
|
||||||
// Remove existing picture blocks first (like PC version)
|
|
||||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||||
if f.Meta[i].Type == flac.Picture {
|
if f.Meta[i].Type == flac.Picture {
|
||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
@@ -125,7 +120,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +131,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create vorbis comment block
|
|
||||||
var cmtIdx int = -1
|
var cmtIdx int = -1
|
||||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
@@ -156,7 +149,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
cmt = flacvorbis.New()
|
cmt = flacvorbis.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set metadata fields
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setComment(cmt, "ARTIST", metadata.Artist)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
@@ -188,7 +180,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or add vorbis comment block
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -196,9 +187,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
f.Meta = append(f.Meta, &cmtBlock)
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cover art if provided
|
|
||||||
if len(coverData) > 0 {
|
if len(coverData) > 0 {
|
||||||
// Remove existing picture blocks first
|
|
||||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||||
if f.Meta[i].Type == flac.Picture {
|
if f.Meta[i].Type == flac.Picture {
|
||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
@@ -220,7 +209,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +245,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
}
|
}
|
||||||
// Also try lowercase variant (some encoders use lowercase)
|
|
||||||
if metadata.TrackNumber == 0 {
|
if metadata.TrackNumber == 0 {
|
||||||
trackNum = getComment(cmt, "TRACK")
|
trackNum = getComment(cmt, "TRACK")
|
||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
@@ -269,7 +256,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
}
|
}
|
||||||
// Also try DISC variant
|
|
||||||
if metadata.DiscNumber == 0 {
|
if metadata.DiscNumber == 0 {
|
||||||
discNum = getComment(cmt, "DISC")
|
discNum = getComment(cmt, "DISC")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
@@ -277,7 +263,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try DATE variants
|
|
||||||
if metadata.Date == "" {
|
if metadata.Date == "" {
|
||||||
metadata.Date = getComment(cmt, "YEAR")
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
}
|
}
|
||||||
@@ -293,7 +278,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing (case-insensitive comparison for Vorbis comments)
|
|
||||||
keyUpper := strings.ToUpper(key)
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
comment := cmt.Comments[i]
|
comment := cmt.Comments[i]
|
||||||
@@ -305,7 +289,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add new
|
|
||||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +296,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
|||||||
keyUpper := strings.ToUpper(key) + "="
|
keyUpper := strings.ToUpper(key) + "="
|
||||||
for _, comment := range cmt.Comments {
|
for _, comment := range cmt.Comments {
|
||||||
if len(comment) > len(key) {
|
if len(comment) > len(key) {
|
||||||
// Case-insensitive comparison for Vorbis comments
|
|
||||||
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||||
if commentUpper == keyUpper {
|
if commentUpper == keyUpper {
|
||||||
return comment[len(key)+1:]
|
return comment[len(key)+1:]
|
||||||
@@ -323,7 +305,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileExists checks if a file exists
|
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -381,13 +362,11 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try LYRICS tag first
|
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to UNSYNCEDLYRICS
|
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
@@ -415,16 +394,12 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Read first 4 bytes to detect file type
|
|
||||||
marker := make([]byte, 4)
|
marker := make([]byte, 4)
|
||||||
if _, err := file.Read(marker); err != nil {
|
if _, err := file.Read(marker); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a FLAC file
|
|
||||||
if string(marker) == "fLaC" {
|
if string(marker) == "fLaC" {
|
||||||
// Continue reading FLAC metadata
|
|
||||||
// Read metadata block header (4 bytes)
|
|
||||||
header := make([]byte, 4)
|
header := make([]byte, 4)
|
||||||
if _, err := file.Read(header); err != nil {
|
if _, err := file.Read(header); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
@@ -435,19 +410,15 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read STREAMINFO block (34 bytes minimum)
|
|
||||||
streamInfo := make([]byte, 34)
|
streamInfo := make([]byte, 34)
|
||||||
if _, err := file.Read(streamInfo); err != nil {
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse sample rate (20 bits starting at byte 10)
|
|
||||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||||
|
|
||||||
// Parse bits per sample (5 bits)
|
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||||
|
|
||||||
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
|
|
||||||
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||||
int64(streamInfo[14])<<24 |
|
int64(streamInfo[14])<<24 |
|
||||||
int64(streamInfo[15])<<16 |
|
int64(streamInfo[15])<<16 |
|
||||||
@@ -461,17 +432,14 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
file.Seek(0, 0)
|
||||||
// First 4 bytes are size, next 4 should be "ftyp"
|
|
||||||
file.Seek(0, 0) // Reset to beginning
|
|
||||||
header8 := make([]byte, 8)
|
header8 := make([]byte, 8)
|
||||||
if _, err := file.Read(header8); err != nil {
|
if _, err := file.Read(header8); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(header8[4:8]) == "ftyp" {
|
if string(header8[4:8]) == "ftyp" {
|
||||||
// It's an M4A/MP4 file, use M4A quality reader
|
file.Close()
|
||||||
file.Close() // Close before calling GetM4AQuality which opens the file again
|
|
||||||
return GetM4AQuality(filePath)
|
return GetM4AQuality(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,41 +451,33 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||||
// This is a simplified implementation that writes metadata to the file
|
|
||||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
// Read the entire file
|
|
||||||
data, err := os.ReadFile(filePath)
|
data, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read M4A file: %w", err)
|
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find moov atom position
|
|
||||||
moovPos := findAtom(data, "moov", 0)
|
moovPos := findAtom(data, "moov", 0)
|
||||||
if moovPos < 0 {
|
if moovPos < 0 {
|
||||||
return fmt.Errorf("moov atom not found in M4A file")
|
return fmt.Errorf("moov atom not found in M4A file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find udta atom inside moov, or create one
|
|
||||||
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
|
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
|
||||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||||
|
|
||||||
// Build new metadata atoms
|
|
||||||
metaAtom := buildMetaAtom(metadata, coverData)
|
metaAtom := buildMetaAtom(metadata, coverData)
|
||||||
|
|
||||||
var newData []byte
|
var newData []byte
|
||||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||||
// udta exists, find meta inside it or replace
|
|
||||||
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
|
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
|
||||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||||
|
|
||||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||||
// Replace existing meta atom
|
|
||||||
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
|
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
|
||||||
newData = append(newData, data[:metaPos]...)
|
newData = append(newData, data[:metaPos]...)
|
||||||
newData = append(newData, metaAtom...)
|
newData = append(newData, metaAtom...)
|
||||||
newData = append(newData, data[metaPos+metaSize:]...)
|
newData = append(newData, data[metaPos+metaSize:]...)
|
||||||
} else {
|
} else {
|
||||||
// Add meta atom to udta
|
|
||||||
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||||
newUdtaSize := 8 + len(newUdtaContent)
|
newUdtaSize := 8 + len(newUdtaContent)
|
||||||
newUdta := make([]byte, 4)
|
newUdta := make([]byte, 4)
|
||||||
@@ -533,7 +493,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new udta with meta
|
|
||||||
udtaContent := metaAtom
|
udtaContent := metaAtom
|
||||||
udtaSize := 8 + len(udtaContent)
|
udtaSize := 8 + len(udtaContent)
|
||||||
newUdta := make([]byte, 4)
|
newUdta := make([]byte, 4)
|
||||||
@@ -544,21 +503,18 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
newUdta = append(newUdta, []byte("udta")...)
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
newUdta = append(newUdta, udtaContent...)
|
newUdta = append(newUdta, udtaContent...)
|
||||||
|
|
||||||
// Insert udta at end of moov
|
|
||||||
insertPos := moovPos + moovSize
|
insertPos := moovPos + moovSize
|
||||||
newData = append(newData, data[:insertPos]...)
|
newData = append(newData, data[:insertPos]...)
|
||||||
newData = append(newData, newUdta...)
|
newData = append(newData, newUdta...)
|
||||||
newData = append(newData, data[insertPos:]...)
|
newData = append(newData, data[insertPos:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update moov size
|
|
||||||
newMoovSize := moovSize + len(newData) - len(data)
|
newMoovSize := moovSize + len(newData) - len(data)
|
||||||
newData[moovPos] = byte(newMoovSize >> 24)
|
newData[moovPos] = byte(newMoovSize >> 24)
|
||||||
newData[moovPos+1] = byte(newMoovSize >> 16)
|
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||||
newData[moovPos+2] = byte(newMoovSize >> 8)
|
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||||
newData[moovPos+3] = byte(newMoovSize)
|
newData[moovPos+3] = byte(newMoovSize)
|
||||||
|
|
||||||
// Write back to file
|
|
||||||
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write M4A file: %w", err)
|
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -585,55 +541,44 @@ func findAtom(data []byte, name string, offset int) int {
|
|||||||
|
|
||||||
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||||
// Build ilst content
|
|
||||||
var ilst []byte
|
var ilst []byte
|
||||||
|
|
||||||
// ©nam - Title
|
|
||||||
if metadata.Title != "" {
|
if metadata.Title != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©ART - Artist
|
|
||||||
if metadata.Artist != "" {
|
if metadata.Artist != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©alb - Album
|
|
||||||
if metadata.Album != "" {
|
if metadata.Album != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// aART - Album Artist
|
|
||||||
if metadata.AlbumArtist != "" {
|
if metadata.AlbumArtist != "" {
|
||||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©day - Year/Date
|
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// trkn - Track Number
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk - Disc Number
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©lyr - Lyrics
|
|
||||||
if metadata.Lyrics != "" {
|
if metadata.Lyrics != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// covr - Cover Art
|
|
||||||
if len(coverData) > 0 {
|
if len(coverData) > 0 {
|
||||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build ilst atom
|
|
||||||
ilstSize := 8 + len(ilst)
|
ilstSize := 8 + len(ilst)
|
||||||
ilstAtom := make([]byte, 4)
|
ilstAtom := make([]byte, 4)
|
||||||
ilstAtom[0] = byte(ilstSize >> 24)
|
ilstAtom[0] = byte(ilstSize >> 24)
|
||||||
@@ -643,7 +588,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||||
ilstAtom = append(ilstAtom, ilst...)
|
ilstAtom = append(ilstAtom, ilst...)
|
||||||
|
|
||||||
// Build hdlr atom (required for meta)
|
|
||||||
hdlr := []byte{
|
hdlr := []byte{
|
||||||
0, 0, 0, 33, // size = 33
|
0, 0, 0, 33, // size = 33
|
||||||
'h', 'd', 'l', 'r',
|
'h', 'd', 'l', 'r',
|
||||||
@@ -656,7 +600,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
0, // null terminator
|
0, // null terminator
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build meta atom
|
|
||||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||||
metaContent = append(metaContent, ilstAtom...)
|
metaContent = append(metaContent, ilstAtom...)
|
||||||
|
|
||||||
@@ -676,7 +619,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
func buildTextAtom(name, value string) []byte {
|
func buildTextAtom(name, value string) []byte {
|
||||||
valueBytes := []byte(value)
|
valueBytes := []byte(value)
|
||||||
|
|
||||||
// data atom
|
|
||||||
dataSize := 16 + len(valueBytes)
|
dataSize := 16 + len(valueBytes)
|
||||||
dataAtom := make([]byte, 4)
|
dataAtom := make([]byte, 4)
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
@@ -688,7 +630,6 @@ func buildTextAtom(name, value string) []byte {
|
|||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
dataAtom = append(dataAtom, valueBytes...)
|
dataAtom = append(dataAtom, valueBytes...)
|
||||||
|
|
||||||
// container atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -703,7 +644,6 @@ func buildTextAtom(name, value string) []byte {
|
|||||||
|
|
||||||
// buildTrackNumberAtom builds trkn atom
|
// buildTrackNumberAtom builds trkn atom
|
||||||
func buildTrackNumberAtom(track, total int) []byte {
|
func buildTrackNumberAtom(track, total int) []byte {
|
||||||
// data atom with track number
|
|
||||||
dataAtom := []byte{
|
dataAtom := []byte{
|
||||||
0, 0, 0, 24, // size
|
0, 0, 0, 24, // size
|
||||||
'd', 'a', 't', 'a',
|
'd', 'a', 't', 'a',
|
||||||
@@ -715,7 +655,6 @@ func buildTrackNumberAtom(track, total int) []byte {
|
|||||||
0, 0, // padding
|
0, 0, // padding
|
||||||
}
|
}
|
||||||
|
|
||||||
// trkn atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -730,7 +669,6 @@ func buildTrackNumberAtom(track, total int) []byte {
|
|||||||
|
|
||||||
// buildDiscNumberAtom builds disk atom
|
// buildDiscNumberAtom builds disk atom
|
||||||
func buildDiscNumberAtom(disc, total int) []byte {
|
func buildDiscNumberAtom(disc, total int) []byte {
|
||||||
// data atom with disc number
|
|
||||||
dataAtom := []byte{
|
dataAtom := []byte{
|
||||||
0, 0, 0, 22, // size
|
0, 0, 0, 22, // size
|
||||||
'd', 'a', 't', 'a',
|
'd', 'a', 't', 'a',
|
||||||
@@ -741,7 +679,6 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
|||||||
byte(total >> 8), byte(total), // total discs
|
byte(total >> 8), byte(total), // total discs
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -756,13 +693,11 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
|||||||
|
|
||||||
// buildCoverAtom builds covr atom with image data
|
// buildCoverAtom builds covr atom with image data
|
||||||
func buildCoverAtom(coverData []byte) []byte {
|
func buildCoverAtom(coverData []byte) []byte {
|
||||||
// Detect image type (JPEG = 13, PNG = 14)
|
|
||||||
imageType := byte(13) // default JPEG
|
imageType := byte(13) // default JPEG
|
||||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||||
imageType = 14 // PNG
|
imageType = 14 // PNG
|
||||||
}
|
}
|
||||||
|
|
||||||
// data atom
|
|
||||||
dataSize := 16 + len(coverData)
|
dataSize := 16 + len(coverData)
|
||||||
dataAtom := make([]byte, 4)
|
dataAtom := make([]byte, 4)
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
@@ -774,7 +709,6 @@ func buildCoverAtom(coverData []byte) []byte {
|
|||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
dataAtom = append(dataAtom, coverData...)
|
dataAtom = append(dataAtom, coverData...)
|
||||||
|
|
||||||
// covr atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -794,24 +728,18 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
|
||||||
moovPos := findAtom(data, "moov", 0)
|
moovPos := findAtom(data, "moov", 0)
|
||||||
if moovPos < 0 {
|
if moovPos < 0 {
|
||||||
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for mp4a or alac atom which contains audio info
|
|
||||||
// This is a simplified search - real implementation would traverse the atom tree
|
|
||||||
for i := moovPos; i < len(data)-20; i++ {
|
for i := moovPos; i < len(data)-20; i++ {
|
||||||
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||||
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
|
||||||
if i+24 < len(data) {
|
if i+24 < len(data) {
|
||||||
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
||||||
// For AAC, bit depth is typically 16
|
|
||||||
bitDepth := 16
|
bitDepth := 16
|
||||||
if string(data[i:i+4]) == "alac" {
|
if string(data[i:i+4]) == "alac" {
|
||||||
// ALAC can have higher bit depth, check esds or alac specific data
|
bitDepth = 24
|
||||||
bitDepth = 24 // Assume 24-bit for ALAC
|
|
||||||
}
|
}
|
||||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-12
@@ -35,7 +35,7 @@ func GetTrackIDCache() *TrackIDCache {
|
|||||||
trackIDCacheOnce.Do(func() {
|
trackIDCacheOnce.Do(func() {
|
||||||
globalTrackIDCache = &TrackIDCache{
|
globalTrackIDCache = &TrackIDCache{
|
||||||
cache: make(map[string]*TrackIDCacheEntry),
|
cache: make(map[string]*TrackIDCacheEntry),
|
||||||
ttl: 30 * time.Minute, // Cache for 30 minutes
|
ttl: 30 * time.Minute,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalTrackIDCache
|
return globalTrackIDCache
|
||||||
@@ -124,6 +124,7 @@ type ParallelDownloadResult struct {
|
|||||||
|
|
||||||
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
||||||
// This runs while the main audio download is happening
|
// This runs while the main audio download is happening
|
||||||
|
// durationMs: track duration in milliseconds for lyrics matching
|
||||||
func FetchCoverAndLyricsParallel(
|
func FetchCoverAndLyricsParallel(
|
||||||
coverURL string,
|
coverURL string,
|
||||||
maxQualityCover bool,
|
maxQualityCover bool,
|
||||||
@@ -131,11 +132,11 @@ func FetchCoverAndLyricsParallel(
|
|||||||
trackName string,
|
trackName string,
|
||||||
artistName string,
|
artistName string,
|
||||||
embedLyrics bool,
|
embedLyrics bool,
|
||||||
|
durationMs int64,
|
||||||
) *ParallelDownloadResult {
|
) *ParallelDownloadResult {
|
||||||
result := &ParallelDownloadResult{}
|
result := &ParallelDownloadResult{}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
// Download cover in parallel
|
|
||||||
if coverURL != "" {
|
if coverURL != "" {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -159,13 +160,13 @@ func FetchCoverAndLyricsParallel(
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Println("[Parallel] Starting lyrics fetch...")
|
fmt.Println("[Parallel] Starting lyrics fetch...")
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.LyricsErr = err
|
result.LyricsErr = err
|
||||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
result.LyricsData = lyrics
|
result.LyricsData = lyrics
|
||||||
// Use LRC with metadata headers (like PC version)
|
|
||||||
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||||
} else {
|
} else {
|
||||||
@@ -202,12 +203,10 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
||||||
cache := GetTrackIDCache()
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
// Limit concurrent pre-warm requests
|
semaphore := make(chan struct{}, 3)
|
||||||
semaphore := make(chan struct{}, 3) // Max 3 concurrent
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, req := range requests {
|
for _, req := range requests {
|
||||||
// Skip if already cached
|
|
||||||
if cached := cache.Get(req.ISRC); cached != nil {
|
if cached := cache.Get(req.ISRC); cached != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -252,11 +251,9 @@ func preWarmQobuzCache(isrc string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||||
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
|
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
if err == nil && availability != nil && availability.Amazon {
|
if err == nil && availability != nil && availability.Amazon {
|
||||||
// Store Amazon URL in cache (using ISRC as key)
|
|
||||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||||
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
||||||
}
|
}
|
||||||
@@ -270,10 +267,8 @@ func preWarmAmazonCache(isrc, spotifyID string) {
|
|||||||
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
||||||
func PreWarmCache(tracksJSON string) error {
|
func PreWarmCache(tracksJSON string) error {
|
||||||
var requests []PreWarmCacheRequest
|
var requests []PreWarmCacheRequest
|
||||||
// Parse JSON (simplified - in production use proper JSON parsing)
|
|
||||||
// For now, this is called from exports.go with proper parsing
|
|
||||||
|
|
||||||
go PreWarmTrackCache(requests) // Run in background
|
go PreWarmTrackCache(requests)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,16 +44,14 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// getProgress returns current download progress from multi-progress system
|
// getProgress returns current download progress from multi-progress system
|
||||||
// Returns first active item's progress for backward compatibility
|
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
|
|
||||||
// Find first active item
|
|
||||||
for _, item := range multiProgress.Items {
|
for _, item := range multiProgress.Items {
|
||||||
return DownloadProgress{
|
return DownloadProgress{
|
||||||
CurrentFile: item.ItemID,
|
CurrentFile: item.ItemID,
|
||||||
Progress: item.Progress * 100, // Convert to percentage
|
Progress: item.Progress * 100,
|
||||||
BytesTotal: item.BytesTotal,
|
BytesTotal: item.BytesTotal,
|
||||||
BytesReceived: item.BytesReceived,
|
BytesReceived: item.BytesReceived,
|
||||||
IsDownloading: item.IsDownloading,
|
IsDownloading: item.IsDownloading,
|
||||||
@@ -249,10 +247,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
|
|
||||||
// Update progress when we've received at least 64KB since last update
|
|
||||||
// Also update on first write to show download has started
|
|
||||||
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
// Calculate speed (MB/s) based on bytes received since last update
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
elapsed := now.Sub(pw.lastTime).Seconds()
|
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||||
var speedMBps float64
|
var speedMBps float64
|
||||||
|
|||||||
+2
-28
@@ -1,8 +1,8 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -25,7 +25,6 @@ type QobuzDownloader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Qobuz downloader instance for connection reuse
|
|
||||||
globalQobuzDownloader *QobuzDownloader
|
globalQobuzDownloader *QobuzDownloader
|
||||||
qobuzDownloaderOnce sync.Once
|
qobuzDownloaderOnce sync.Once
|
||||||
)
|
)
|
||||||
@@ -66,22 +65,17 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split expected artists by common separators (comma, feat, ft., &, and)
|
|
||||||
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
|
||||||
expectedArtists := qobuzSplitArtists(normExpected)
|
expectedArtists := qobuzSplitArtists(normExpected)
|
||||||
foundArtists := qobuzSplitArtists(normFound)
|
foundArtists := qobuzSplitArtists(normFound)
|
||||||
|
|
||||||
// Check if ANY expected artist matches ANY found artist
|
|
||||||
for _, exp := range expectedArtists {
|
for _, exp := range expectedArtists {
|
||||||
for _, fnd := range foundArtists {
|
for _, fnd := range foundArtists {
|
||||||
if exp == fnd {
|
if exp == fnd {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Also check contains for partial matches
|
|
||||||
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Check same words different order
|
|
||||||
if qobuzSameWordsUnordered(exp, fnd) {
|
if qobuzSameWordsUnordered(exp, fnd) {
|
||||||
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
||||||
return true
|
return true
|
||||||
@@ -89,8 +83,6 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
|
||||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
|
||||||
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
||||||
foundLatin := qobuzIsLatinScript(foundArtist)
|
foundLatin := qobuzIsLatinScript(foundArtist)
|
||||||
if expectedLatin != foundLatin {
|
if expectedLatin != foundLatin {
|
||||||
@@ -855,7 +847,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("no Qobuz API available")
|
return "", fmt.Errorf("no Qobuz API available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use parallel approach - request from all APIs simultaneously
|
|
||||||
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -899,7 +890,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -909,16 +899,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
written, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,7 +913,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
// Check for any errors
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
@@ -970,18 +956,15 @@ type QobuzDownloadResult struct {
|
|||||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert expected duration from ms to seconds
|
|
||||||
expectedDurationSec := req.DurationMS / 1000
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority)
|
|
||||||
if req.QobuzID != "" {
|
if req.QobuzID != "" {
|
||||||
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
||||||
var trackID int64
|
var trackID int64
|
||||||
@@ -1052,7 +1035,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -1064,7 +1046,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
filename = sanitizeFilename(filename) + ".flac"
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
outputPath := filepath.Join(req.OutputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
@@ -1083,12 +1064,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
// Get actual quality from track metadata
|
|
||||||
actualBitDepth := track.MaximumBitDepth
|
actualBitDepth := track.MaximumBitDepth
|
||||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||||
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
@@ -1106,6 +1085,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
req.TrackName,
|
req.TrackName,
|
||||||
req.ArtistName,
|
req.ArtistName,
|
||||||
req.EmbedLyrics,
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -1120,16 +1100,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using parallel-fetched cover data
|
|
||||||
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
|
|
||||||
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
|
|
||||||
albumName := track.Album.Title
|
albumName := track.Album.Title
|
||||||
if req.AlbumName != "" {
|
if req.AlbumName != "" {
|
||||||
albumName = req.AlbumName
|
albumName = req.AlbumName
|
||||||
@@ -1147,7 +1122,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
|
|||||||
@@ -30,31 +30,25 @@ func (r *RateLimiter) WaitForSlot() {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Remove timestamps outside the window
|
|
||||||
r.cleanOldTimestamps(now)
|
r.cleanOldTimestamps(now)
|
||||||
|
|
||||||
// If under limit, record and return immediately
|
|
||||||
if len(r.timestamps) < r.maxRequests {
|
if len(r.timestamps) < r.maxRequests {
|
||||||
r.timestamps = append(r.timestamps, now)
|
r.timestamps = append(r.timestamps, now)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate wait time until oldest timestamp expires
|
|
||||||
oldestTimestamp := r.timestamps[0]
|
oldestTimestamp := r.timestamps[0]
|
||||||
waitUntil := oldestTimestamp.Add(r.window)
|
waitUntil := oldestTimestamp.Add(r.window)
|
||||||
waitDuration := waitUntil.Sub(now)
|
waitDuration := waitUntil.Sub(now)
|
||||||
|
|
||||||
if waitDuration > 0 {
|
if waitDuration > 0 {
|
||||||
// Release lock while waiting
|
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
time.Sleep(waitDuration)
|
time.Sleep(waitDuration)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|
||||||
// Clean again after waiting
|
|
||||||
r.cleanOldTimestamps(time.Now())
|
r.cleanOldTimestamps(time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record this request
|
|
||||||
r.timestamps = append(r.timestamps, time.Now())
|
r.timestamps = append(r.timestamps, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-30
@@ -31,7 +31,6 @@ type TrackAvailability struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global SongLink client instance for connection reuse
|
|
||||||
globalSongLinkClient *SongLinkClient
|
globalSongLinkClient *SongLinkClient
|
||||||
songLinkClientOnce sync.Once
|
songLinkClientOnce sync.Once
|
||||||
)
|
)
|
||||||
@@ -40,7 +39,7 @@ var (
|
|||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
songLinkClientOnce.Do(func() {
|
songLinkClientOnce.Do(func() {
|
||||||
globalSongLinkClient = &SongLinkClient{
|
globalSongLinkClient = &SongLinkClient{
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
client: NewHTTPClientWithTimeout(SongLinkTimeout),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalSongLinkClient
|
return globalSongLinkClient
|
||||||
@@ -48,15 +47,12 @@ func NewSongLinkClient() *SongLinkClient {
|
|||||||
|
|
||||||
// CheckTrackAvailability checks track availability on streaming platforms
|
// CheckTrackAvailability checks track availability on streaming platforms
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
// Validate Spotify ID format (should be 22 characters alphanumeric)
|
|
||||||
if spotifyTrackID == "" {
|
if spotifyTrackID == "" {
|
||||||
return nil, fmt.Errorf("spotify track ID is empty")
|
return nil, fmt.Errorf("spotify track ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter - blocks until request is allowed
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||||
|
|
||||||
@@ -68,7 +64,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use retry logic with User-Agent
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := DefaultRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,7 +71,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||||
}
|
}
|
||||||
@@ -109,27 +103,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
SpotifyID: spotifyTrackID,
|
SpotifyID: spotifyTrackID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
|
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
|
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
}
|
}
|
||||||
@@ -191,12 +180,9 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
|
|
||||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||||
func extractDeezerIDFromURL(deezerURL string) string {
|
func extractDeezerIDFromURL(deezerURL string) string {
|
||||||
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
|
|
||||||
parts := strings.Split(deezerURL, "/")
|
parts := strings.Split(deezerURL, "/")
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
// Get the last part which should be the ID
|
|
||||||
lastPart := parts[len(parts)-1]
|
lastPart := parts[len(parts)-1]
|
||||||
// Remove any query parameters
|
|
||||||
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
||||||
lastPart = lastPart[:idx]
|
lastPart = lastPart[:idx]
|
||||||
}
|
}
|
||||||
@@ -274,7 +260,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
|||||||
SpotifyID: spotifyAlbumID,
|
SpotifyID: spotifyAlbumID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
@@ -309,13 +294,10 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
return nil, fmt.Errorf("deezer track ID is empty")
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build Deezer URL
|
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
// Build API URL using Deezer URL as source
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||||
|
|
||||||
@@ -371,25 +353,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
DeezerID: deezerTrackID,
|
DeezerID: deezerTrackID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Spotify
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
// Extract Spotify ID from URL
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer URL
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
}
|
}
|
||||||
@@ -459,24 +436,20 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
|
|
||||||
availability := &TrackAvailability{}
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
// Check Spotify
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
@@ -488,10 +461,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
|
|
||||||
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
|
|
||||||
parts := strings.Split(spotifyURL, "/track/")
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
// Get the ID part and remove any query parameters
|
|
||||||
idPart := parts[1]
|
idPart := parts[1]
|
||||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
|
|||||||
+1
-15
@@ -84,12 +84,10 @@ func HasSpotifyCredentials() bool {
|
|||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
// Check custom credentials first
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check environment variables
|
|
||||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -102,12 +100,10 @@ func getCredentials() (string, string, error) {
|
|||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
// Check custom credentials first
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return customClientID, customClientSecret, nil
|
return customClientID, customClientSecret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check environment variables
|
|
||||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
|
||||||
@@ -115,14 +111,12 @@ func getCredentials() (string, string, error) {
|
|||||||
return clientID, clientSecret, nil
|
return clientID, clientSecret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// No credentials available
|
|
||||||
return "", "", ErrNoSpotifyCredentials
|
return "", "", ErrNoSpotifyCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
// Returns error if credentials are not configured
|
// Returns error if credentials are not configured
|
||||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||||
// Get credentials - will error if not configured
|
|
||||||
clientID, clientSecret, err := getCredentials()
|
clientID, clientSecret, err := getCredentials()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -131,7 +125,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
|||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
@@ -393,10 +387,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
|
|
||||||
// SearchAll searches for tracks and artists on Spotify
|
// SearchAll searches for tracks and artists on Spotify
|
||||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
// Create cache key
|
|
||||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -456,7 +448,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit artists to artistLimit
|
|
||||||
artistCount := len(response.Artists.Items)
|
artistCount := len(response.Artists.Items)
|
||||||
if artistCount > artistLimit {
|
if artistCount > artistLimit {
|
||||||
artistCount = artistLimit
|
artistCount = artistLimit
|
||||||
@@ -473,7 +464,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -510,7 +500,6 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -610,7 +599,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.albumCache[albumID] = &cacheEntry{
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -768,7 +756,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -856,7 +843,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
Albums: albums,
|
Albums: albums,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.artistCache[artistID] = &cacheEntry{
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
|
|||||||
+10
-90
@@ -1,8 +1,8 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
@@ -31,7 +31,6 @@ type TidalDownloader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Tidal downloader instance for token reuse
|
|
||||||
globalTidalDownloader *TidalDownloader
|
globalTidalDownloader *TidalDownloader
|
||||||
tidalDownloaderOnce sync.Once
|
tidalDownloaderOnce sync.Once
|
||||||
)
|
)
|
||||||
@@ -118,7 +117,6 @@ func NewTidalDownloader() *TidalDownloader {
|
|||||||
clientSecret: string(clientSecret),
|
clientSecret: string(clientSecret),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get first available API
|
|
||||||
apis := globalTidalDownloader.GetAvailableAPIs()
|
apis := globalTidalDownloader.GetAvailableAPIs()
|
||||||
if len(apis) > 0 {
|
if len(apis) > 0 {
|
||||||
globalTidalDownloader.apiURL = apis[0]
|
globalTidalDownloader.apiURL = apis[0]
|
||||||
@@ -130,16 +128,14 @@ func NewTidalDownloader() *TidalDownloader {
|
|||||||
// GetAvailableAPIs returns list of available Tidal APIs
|
// GetAvailableAPIs returns list of available Tidal APIs
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
// Priority 1: APIs that return FULL tracks (not PREVIEW)
|
"dGlkYWwua2lub3BsdXMub25saW5l",
|
||||||
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - returns FULL
|
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
|
||||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
|
"dHJpdG9uLnNxdWlkLnd0Zg==",
|
||||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
"dm9nZWwucXFkbC5zaXRl",
|
||||||
// Priority 2: qqdl.site APIs (often return PREVIEW only)
|
"bWF1cy5xcWRsLnNpdGU=",
|
||||||
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
"aHVuZC5xcWRsLnNpdGU=",
|
||||||
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
"a2F0emUucXFkbC5zaXRl",
|
||||||
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
"d29sZi5xcWRsLnNpdGU=",
|
||||||
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
|
||||||
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -159,7 +155,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|||||||
t.tokenMu.Lock()
|
t.tokenMu.Lock()
|
||||||
defer t.tokenMu.Unlock()
|
defer t.tokenMu.Unlock()
|
||||||
|
|
||||||
// Return cached token if still valid (with 60s buffer)
|
|
||||||
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
|
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
|
||||||
return t.cachedToken, nil
|
return t.cachedToken, nil
|
||||||
}
|
}
|
||||||
@@ -194,7 +189,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the token
|
|
||||||
t.cachedToken = result.AccessToken
|
t.cachedToken = result.AccessToken
|
||||||
if result.ExpiresIn > 0 {
|
if result.ExpiresIn > 0 {
|
||||||
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||||
@@ -386,22 +380,17 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
queries = append(queries, artistName+" "+trackName)
|
queries = append(queries, artistName+" "+trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Track name only
|
|
||||||
if trackName != "" {
|
if trackName != "" {
|
||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Romaji versions if Japanese detected (NEW - from PC version)
|
|
||||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||||
// Convert to romaji (hiragana/katakana only, kanji stays)
|
|
||||||
romajiTrack := JapaneseToRomaji(trackName)
|
romajiTrack := JapaneseToRomaji(trackName)
|
||||||
romajiArtist := JapaneseToRomaji(artistName)
|
romajiArtist := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
// Clean and remove ALL non-ASCII characters (including kanji)
|
|
||||||
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||||
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||||
|
|
||||||
// Artist + Track romaji (cleaned to ASCII only)
|
|
||||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
if !containsQuery(queries, romajiQuery) {
|
if !containsQuery(queries, romajiQuery) {
|
||||||
@@ -410,14 +399,12 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track romaji only (cleaned)
|
|
||||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||||
if !containsQuery(queries, cleanRomajiTrack) {
|
if !containsQuery(queries, cleanRomajiTrack) {
|
||||||
queries = append(queries, cleanRomajiTrack)
|
queries = append(queries, cleanRomajiTrack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try with partial romaji (artist + cleaned track)
|
|
||||||
if artistName != "" && cleanRomajiTrack != "" {
|
if artistName != "" && cleanRomajiTrack != "" {
|
||||||
partialQuery := artistName + " " + cleanRomajiTrack
|
partialQuery := artistName + " " + cleanRomajiTrack
|
||||||
if !containsQuery(queries, partialQuery) {
|
if !containsQuery(queries, partialQuery) {
|
||||||
@@ -426,7 +413,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 4: Artist only as last resort
|
|
||||||
if artistName != "" {
|
if artistName != "" {
|
||||||
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||||
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
||||||
@@ -436,7 +422,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
|
|
||||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
||||||
|
|
||||||
// Collect all search results from all queries
|
|
||||||
var allTracks []TidalTrack
|
var allTracks []TidalTrack
|
||||||
searchedQueries := make(map[string]bool)
|
searchedQueries := make(map[string]bool)
|
||||||
|
|
||||||
@@ -486,7 +471,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
for i := range result.Items {
|
for i := range result.Items {
|
||||||
if result.Items[i].ISRC == spotifyISRC {
|
if result.Items[i].ISRC == spotifyISRC {
|
||||||
track := &result.Items[i]
|
track := &result.Items[i]
|
||||||
// Verify duration if provided
|
|
||||||
if expectedDuration > 0 {
|
if expectedDuration > 0 {
|
||||||
durationDiff := track.Duration - expectedDuration
|
durationDiff := track.Duration - expectedDuration
|
||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
@@ -496,7 +480,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
// Duration mismatch, continue searching
|
|
||||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||||
expectedDuration, track.Duration)
|
expectedDuration, track.Duration)
|
||||||
} else {
|
} else {
|
||||||
@@ -515,7 +498,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
return nil, fmt.Errorf("no tracks found for any search query")
|
return nil, fmt.Errorf("no tracks found for any search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 1: Match by ISRC (exact match) WITH title verification
|
|
||||||
if spotifyISRC != "" {
|
if spotifyISRC != "" {
|
||||||
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
||||||
var isrcMatches []*TidalTrack
|
var isrcMatches []*TidalTrack
|
||||||
@@ -527,7 +509,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(isrcMatches) > 0 {
|
if len(isrcMatches) > 0 {
|
||||||
// Verify duration first (most important check)
|
|
||||||
if expectedDuration > 0 {
|
if expectedDuration > 0 {
|
||||||
var durationVerifiedMatches []*TidalTrack
|
var durationVerifiedMatches []*TidalTrack
|
||||||
for _, track := range isrcMatches {
|
for _, track := range isrcMatches {
|
||||||
@@ -535,37 +516,31 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
// Allow 3 seconds tolerance for duration (same as PC version)
|
|
||||||
if durationDiff <= 3 {
|
if durationDiff <= 3 {
|
||||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(durationVerifiedMatches) > 0 {
|
if len(durationVerifiedMatches) > 0 {
|
||||||
// Return first duration-verified match
|
|
||||||
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||||
return durationVerifiedMatches[0], nil
|
return durationVerifiedMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC matches but duration doesn't - this is likely wrong version
|
|
||||||
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||||
expectedDuration, isrcMatches[0].Duration)
|
expectedDuration, isrcMatches[0].Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration to verify, just return first ISRC match
|
|
||||||
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
return isrcMatches[0], nil
|
return isrcMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ISRC was provided but no match found, return error
|
|
||||||
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
||||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Match by duration (within tolerance) + prefer best quality
|
|
||||||
if expectedDuration > 0 {
|
if expectedDuration > 0 {
|
||||||
tolerance := 3 // 3 seconds tolerance
|
tolerance := 3 // 3 seconds tolerance
|
||||||
var durationMatches []*TidalTrack
|
var durationMatches []*TidalTrack
|
||||||
@@ -582,7 +557,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(durationMatches) > 0 {
|
if len(durationMatches) > 0 {
|
||||||
// Find best quality among duration matches
|
|
||||||
bestMatch := durationMatches[0]
|
bestMatch := durationMatches[0]
|
||||||
for _, track := range durationMatches {
|
for _, track := range durationMatches {
|
||||||
for _, tag := range track.MediaMetadata.Tags {
|
for _, tag := range track.MediaMetadata.Tags {
|
||||||
@@ -598,7 +572,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Just take the best quality from first results
|
|
||||||
bestMatch := &allTracks[0]
|
bestMatch := &allTracks[0]
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
@@ -662,12 +635,10 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
resultChan := make(chan tidalAPIResult, len(apis))
|
resultChan := make(chan tidalAPIResult, len(apis))
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// Start all requests in parallel
|
|
||||||
for _, apiURL := range apis {
|
for _, apiURL := range apis {
|
||||||
go func(api string) {
|
go func(api string) {
|
||||||
reqStart := time.Now()
|
reqStart := time.Now()
|
||||||
|
|
||||||
// Create client with timeout for parallel requests
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 15 * time.Second,
|
Timeout: 15 * time.Second,
|
||||||
}
|
}
|
||||||
@@ -698,7 +669,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try v2 format first (object with manifest)
|
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||||
@@ -716,7 +686,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
|
||||||
var v1Responses []struct {
|
var v1Responses []struct {
|
||||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||||
}
|
}
|
||||||
@@ -738,13 +707,11 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
}(apiURL)
|
}(apiURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect results - return first success
|
|
||||||
var errors []string
|
var errors []string
|
||||||
|
|
||||||
for i := 0; i < len(apis); i++ {
|
for i := 0; i < len(apis); i++ {
|
||||||
result := <-resultChan
|
result := <-resultChan
|
||||||
if result.err == nil {
|
if result.err == nil {
|
||||||
// First success - use this one
|
|
||||||
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
||||||
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
||||||
|
|
||||||
@@ -777,7 +744,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
|
|||||||
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use parallel approach - request from all APIs simultaneously
|
|
||||||
_, info, err := getDownloadURLParallel(apis, trackID, quality)
|
_, info, err := getDownloadURLParallel(apis, trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
@@ -795,16 +761,13 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
manifestStr := string(manifestBytes)
|
manifestStr := string(manifestBytes)
|
||||||
|
|
||||||
// Debug: log first 500 chars of manifest for debugging
|
|
||||||
manifestPreview := manifestStr
|
manifestPreview := manifestStr
|
||||||
if len(manifestPreview) > 500 {
|
if len(manifestPreview) > 500 {
|
||||||
manifestPreview = manifestPreview[:500] + "..."
|
manifestPreview = manifestPreview[:500] + "..."
|
||||||
}
|
}
|
||||||
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
|
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
|
||||||
|
|
||||||
// Check if it's BTS format (JSON) or DASH format (XML)
|
|
||||||
if strings.HasPrefix(manifestStr, "{") {
|
if strings.HasPrefix(manifestStr, "{") {
|
||||||
// BTS format - JSON with direct URLs
|
|
||||||
var btsManifest TidalBTSManifest
|
var btsManifest TidalBTSManifest
|
||||||
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
||||||
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
||||||
@@ -817,7 +780,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
return btsManifest.URLs[0], "", nil, nil
|
return btsManifest.URLs[0], "", nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DASH format - XML with segments
|
|
||||||
var mpd MPD
|
var mpd MPD
|
||||||
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
||||||
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
||||||
@@ -828,7 +790,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
mediaTemplate := segTemplate.Media
|
mediaTemplate := segTemplate.Media
|
||||||
|
|
||||||
if initURL == "" || mediaTemplate == "" {
|
if initURL == "" || mediaTemplate == "" {
|
||||||
// Fallback: try regex extraction
|
|
||||||
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
||||||
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
||||||
|
|
||||||
@@ -844,11 +805,9 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
|
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unescape HTML entities in URLs
|
|
||||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||||
|
|
||||||
// Calculate segment count from timeline
|
|
||||||
segmentCount := 0
|
segmentCount := 0
|
||||||
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
||||||
for i, seg := range segTemplate.Timeline.Segments {
|
for i, seg := range segTemplate.Timeline.Segments {
|
||||||
@@ -857,10 +816,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
}
|
}
|
||||||
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
|
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
|
||||||
|
|
||||||
// If no segments found via XML, try regex
|
|
||||||
if segmentCount == 0 {
|
if segmentCount == 0 {
|
||||||
fmt.Println("[Tidal] No segments from XML, trying regex...")
|
fmt.Println("[Tidal] No segments from XML, trying regex...")
|
||||||
// Match <S d="..." /> or <S d="..." r="..." />
|
|
||||||
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
|
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
|
||||||
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
||||||
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
|
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
|
||||||
@@ -877,7 +834,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate media URLs for each segment
|
|
||||||
for i := 1; i <= segmentCount; i++ {
|
for i := 1; i <= segmentCount; i++ {
|
||||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
@@ -890,9 +846,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Handle manifest-based download (DASH/BTS)
|
|
||||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||||
// Initialize progress tracking for manifest downloads
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -936,7 +890,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -946,24 +899,19 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
written, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush buffer before checking for errors
|
|
||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
// Check for any errors
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
@@ -980,7 +928,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file size if Content-Length was provided
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
@@ -1003,7 +950,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||||
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
||||||
@@ -1035,7 +981,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes for progress tracking
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -1045,7 +990,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use item progress writer
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
@@ -1068,7 +1012,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file size if Content-Length was provided
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
@@ -1077,21 +1020,15 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
|
|
||||||
// On Android, we can't use ffmpeg, so we save as M4A directly
|
|
||||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||||
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||||
|
|
||||||
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
|
|
||||||
// We just update progress here based on segment count
|
|
||||||
|
|
||||||
out, err := os.Create(m4aPath)
|
out, err := os.Create(m4aPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
||||||
return fmt.Errorf("failed to create M4A file: %w", err)
|
return fmt.Errorf("failed to create M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download initialization segment
|
|
||||||
GoLog("[Tidal] Downloading init segment...\n")
|
GoLog("[Tidal] Downloading init segment...\n")
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
out.Close()
|
out.Close()
|
||||||
@@ -1134,7 +1071,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
return fmt.Errorf("failed to write init segment: %w", err)
|
return fmt.Errorf("failed to write init segment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download media segments with progress
|
|
||||||
totalSegments := len(mediaURLs)
|
totalSegments := len(mediaURLs)
|
||||||
for i, mediaURL := range mediaURLs {
|
for i, mediaURL := range mediaURLs {
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
@@ -1147,7 +1083,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress based on segment count
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progress := float64(i+1) / float64(totalSegments)
|
progress := float64(i+1) / float64(totalSegments)
|
||||||
SetItemProgress(itemID, progress, 0, 0)
|
SetItemProgress(itemID, progress, 0, 0)
|
||||||
@@ -1514,7 +1449,6 @@ func isLatinScript(s string) bool {
|
|||||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
@@ -1582,7 +1516,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
var tidalURL string
|
var tidalURL string
|
||||||
var slErr error
|
var slErr error
|
||||||
|
|
||||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
|
||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
@@ -1593,12 +1526,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if slErr == nil && tidalURL != "" {
|
if slErr == nil && tidalURL != "" {
|
||||||
// Extract track ID and get track info
|
|
||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
if idErr == nil {
|
if idErr == nil {
|
||||||
track, err = downloader.GetTrackInfoByID(trackID)
|
track, err = downloader.GetTrackInfoByID(trackID)
|
||||||
if track != nil {
|
if track != nil {
|
||||||
// Get artist name from track
|
|
||||||
tidalArtist := track.Artist.Name
|
tidalArtist := track.Artist.Name
|
||||||
if len(track.Artists) > 0 {
|
if len(track.Artists) > 0 {
|
||||||
var artistNames []string
|
var artistNames []string
|
||||||
@@ -1608,7 +1539,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
tidalArtist = strings.Join(artistNames, ", ")
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify artist matches (SongLink is already accurate, no title check needed)
|
|
||||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, tidalArtist)
|
req.ArtistName, tidalArtist)
|
||||||
@@ -1680,12 +1610,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||||
|
|
||||||
// Cache the track ID for future use
|
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -1697,7 +1625,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
filename = sanitizeFilename(filename) + ".flac"
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
outputPath := filepath.Join(req.OutputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists (both FLAC and M4A)
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
@@ -1713,14 +1640,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
os.Remove(tmpPath)
|
os.Remove(tmpPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine quality to use (default to LOSSLESS if not specified)
|
|
||||||
quality := req.Quality
|
quality := req.Quality
|
||||||
if quality == "" {
|
if quality == "" {
|
||||||
quality = "LOSSLESS"
|
quality = "LOSSLESS"
|
||||||
}
|
}
|
||||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
|
||||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
@@ -1741,6 +1666,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
req.TrackName,
|
req.TrackName,
|
||||||
req.ArtistName,
|
req.ArtistName,
|
||||||
req.EmbedLyrics,
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -1765,18 +1691,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
|
||||||
// downloadFromManifest saves DASH streams as .m4a (m4aPath already defined above)
|
|
||||||
actualOutputPath := outputPath
|
actualOutputPath := outputPath
|
||||||
if _, err := os.Stat(m4aPath); err == nil {
|
if _, err := os.Stat(m4aPath); err == nil {
|
||||||
// File was saved as M4A, use that path
|
|
||||||
actualOutputPath = m4aPath
|
actualOutputPath = m4aPath
|
||||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||||
} else if _, err := os.Stat(outputPath); err != nil {
|
} else if _, err := os.Stat(outputPath); err != nil {
|
||||||
@@ -1797,7 +1718,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
ISRC: track.ISRC, // Use actual ISRC from Tidal
|
ISRC: track.ISRC, // Use actual ISRC from Tidal
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
|
|||||||
@@ -161,7 +161,8 @@ import Gobackend // Import Go framework
|
|||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error)
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -171,7 +172,8 @@ import Gobackend // Import Go framework
|
|||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let filePath = args["file_path"] as? String ?? ""
|
let filePath = args["file_path"] as? String ?? ""
|
||||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -9,7 +9,6 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
|||||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
|
||||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
@@ -35,7 +34,6 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
final router = ref.watch(_routerProvider);
|
final router = ref.watch(_routerProvider);
|
||||||
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||||
|
|
||||||
// Convert locale string to Locale object
|
|
||||||
Locale? locale;
|
Locale? locale;
|
||||||
if (localeString != 'system') {
|
if (localeString != 'system') {
|
||||||
locale = Locale(localeString);
|
locale = Locale(localeString);
|
||||||
@@ -52,8 +50,7 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||||
themeAnimationCurve: Curves.easeInOut,
|
themeAnimationCurve: Curves.easeInOut,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
// Localization
|
locale: locale,
|
||||||
locale: locale, // null = follow system
|
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
AppLocalizations.delegate,
|
AppLocalizations.delegate,
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.1.0';
|
static const String version = '3.1.1';
|
||||||
static const String buildNumber = '59';
|
static const String buildNumber = '60';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+117
-107
@@ -13,56 +13,57 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get appDescription =>
|
String get appDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Laden Sie Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Startseite';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHistory => 'History';
|
String get navHistory => 'Verlauf';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navSettings => 'Settings';
|
String get navSettings => 'Einstellungen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Store';
|
String get navStore => 'Store';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Home';
|
String get homeTitle => 'Startseite';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
String get homeSearchHint => 'Spotify-URL einfügen oder suchen...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String homeSearchHintExtension(String extensionName) {
|
String homeSearchHintExtension(String extensionName) {
|
||||||
return 'Search with $extensionName...';
|
return 'Mit $extensionName suchen...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
String get homeSupports =>
|
||||||
|
'Unterstützt: Titel, Album, Playlist, Künstler-URLs';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeRecent => 'Recent';
|
String get homeRecent => 'Zuletzt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyTitle => 'History';
|
String get historyTitle => 'Verlauf';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String historyDownloading(int count) {
|
String historyDownloading(int count) {
|
||||||
return 'Downloading ($count)';
|
return 'Wird heruntergeladen ($count)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyDownloaded => 'Downloaded';
|
String get historyDownloaded => 'Heruntergeladen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAll => 'All';
|
String get historyFilterAll => 'Alle';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAlbums => 'Albums';
|
String get historyFilterAlbums => 'Alben';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterSingles => 'Singles';
|
String get historyFilterSingles => 'Singles';
|
||||||
@@ -72,8 +73,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count tracks',
|
other: '$count Titel',
|
||||||
one: '1 track',
|
one: '1 Titel',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -83,93 +84,95 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count albums',
|
other: '$count Alben',
|
||||||
one: '1 album',
|
one: '1 Album',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoDownloads => 'No download history';
|
String get historyNoDownloads => 'Kein Download-Verlauf';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
|
String get historyNoDownloadsSubtitle =>
|
||||||
|
'Heruntergeladene Titel werden hier angezeigt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoAlbums => 'No album downloads';
|
String get historyNoAlbums => 'Keine Album-Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoAlbumsSubtitle =>
|
String get historyNoAlbumsSubtitle =>
|
||||||
'Download multiple tracks from an album to see them here';
|
'Laden Sie mehrere Titel eines Albums herunter, um sie hier zu sehen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoSingles => 'No single downloads';
|
String get historyNoSingles => 'Keine Einzel-Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Einzelne Titel-Downloads werden hier angezeigt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Einstellungen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownload => 'Download';
|
String get settingsDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAppearance => 'Appearance';
|
String get settingsAppearance => 'Erscheinungsbild';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsOptions => 'Options';
|
String get settingsOptions => 'Optionen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsExtensions => 'Extensions';
|
String get settingsExtensions => 'Erweiterungen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAbout => 'About';
|
String get settingsAbout => 'Über';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadTitle => 'Download';
|
String get downloadTitle => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocation => 'Download Location';
|
String get downloadLocation => 'Download-Speicherort';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocationSubtitle => 'Choose where to save files';
|
String get downloadLocationSubtitle =>
|
||||||
|
'Wählen Sie den Speicherort für Dateien';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocationDefault => 'Default location';
|
String get downloadLocationDefault => 'Standard-Speicherort';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultService => 'Default Service';
|
String get downloadDefaultService => 'Standard-Dienst';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
|
String get downloadDefaultServiceSubtitle => 'Dienst für Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultQuality => 'Default Quality';
|
String get downloadDefaultQuality => 'Standard-Qualität';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskQuality => 'Ask Quality Before Download';
|
String get downloadAskQuality => 'Qualität vor Download abfragen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskQualitySubtitle =>
|
String get downloadAskQualitySubtitle =>
|
||||||
'Show quality picker for each download';
|
'Qualitätsauswahl für jeden Download anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Filename Format';
|
String get downloadFilenameFormat => 'Dateinamenformat';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Folder Organization';
|
String get downloadFolderOrganization => 'Ordnerstruktur';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSeparateSingles => 'Separate Singles';
|
String get downloadSeparateSingles => 'Singles trennen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSeparateSinglesSubtitle =>
|
String get downloadSeparateSinglesSubtitle =>
|
||||||
'Put single tracks in a separate folder';
|
'Einzelne Titel in separatem Ordner speichern';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityBest => 'Best Available';
|
String get qualityBest => 'Beste Qualität';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityFlac => 'FLAC';
|
String get qualityFlac => 'FLAC';
|
||||||
@@ -181,179 +184,186 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get quality128 => '128 kbps';
|
String get quality128 => '128 kbps';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTitle => 'Appearance';
|
String get appearanceTitle => 'Erscheinungsbild';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTheme => 'Theme';
|
String get appearanceTheme => 'Design';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeSystem => 'System';
|
String get appearanceThemeSystem => 'System';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeLight => 'Light';
|
String get appearanceThemeLight => 'Hell';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeDark => 'Dark';
|
String get appearanceThemeDark => 'Dunkel';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColor => 'Dynamic Color';
|
String get appearanceDynamicColor => 'Dynamische Farben';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
String get appearanceDynamicColorSubtitle =>
|
||||||
|
'Farben von Ihrem Hintergrundbild verwenden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceAccentColor => 'Accent Color';
|
String get appearanceAccentColor => 'Akzentfarbe';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryView => 'History View';
|
String get appearanceHistoryView => 'Verlaufsansicht';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryViewList => 'List';
|
String get appearanceHistoryViewList => 'Liste';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryViewGrid => 'Grid';
|
String get appearanceHistoryViewGrid => 'Raster';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsTitle => 'Options';
|
String get optionsTitle => 'Optionen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSearchSource => 'Search Source';
|
String get optionsSearchSource => 'Suchquelle';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsPrimaryProvider => 'Primary Provider';
|
String get optionsPrimaryProvider => 'Primärer Anbieter';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsPrimaryProviderSubtitle =>
|
String get optionsPrimaryProviderSubtitle =>
|
||||||
'Service used when searching by track name.';
|
'Dienst für die Suche nach Titelnamen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsUsingExtension(String extensionName) {
|
String optionsUsingExtension(String extensionName) {
|
||||||
return 'Using extension: $extensionName';
|
return 'Erweiterung verwenden: $extensionName';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSwitchBack =>
|
String get optionsSwitchBack =>
|
||||||
'Tap Deezer or Spotify to switch back from extension';
|
'Tippen Sie auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallback => 'Auto Fallback';
|
String get optionsAutoFallback => 'Automatischer Fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallbackSubtitle =>
|
String get optionsAutoFallbackSubtitle =>
|
||||||
'Try other services if download fails';
|
'Andere Dienste versuchen, wenn Download fehlschlägt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
String get optionsUseExtensionProvidersOn =>
|
||||||
|
'Erweiterungen werden zuerst versucht';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
String get optionsUseExtensionProvidersOff =>
|
||||||
|
'Nur integrierte Anbieter verwenden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
String get optionsEmbedLyrics => 'Liedtexte einbetten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsEmbedLyricsSubtitle =>
|
String get optionsEmbedLyricsSubtitle =>
|
||||||
'Embed synced lyrics into FLAC files';
|
'Synchronisierte Liedtexte in FLAC-Dateien einbetten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCover => 'Max Quality Cover';
|
String get optionsMaxQualityCover => 'Maximale Cover-Qualität';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'Cover in höchster Auflösung herunterladen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Parallele Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
String get optionsConcurrentSequential => 'Sequentiell (1 gleichzeitig)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsConcurrentParallel(int count) {
|
String optionsConcurrentParallel(int count) {
|
||||||
return '$count parallel downloads';
|
return '$count parallele Downloads';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentWarning =>
|
String get optionsConcurrentWarning =>
|
||||||
'Parallel downloads may trigger rate limiting';
|
'Parallele Downloads können Ratenlimitierung auslösen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Extension Store';
|
String get optionsExtensionStore => 'Erweiterungs-Store';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
String get optionsExtensionStoreSubtitle =>
|
||||||
|
'Store-Tab in Navigation anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdates => 'Check for Updates';
|
String get optionsCheckUpdates => 'Nach Updates suchen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdatesSubtitle =>
|
String get optionsCheckUpdatesSubtitle =>
|
||||||
'Notify when new version is available';
|
'Benachrichtigen, wenn neue Version verfügbar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUpdateChannel => 'Update Channel';
|
String get optionsUpdateChannel => 'Update-Kanal';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUpdateChannelStable => 'Stable releases only';
|
String get optionsUpdateChannelStable => 'Nur stabile Versionen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUpdateChannelPreview => 'Get preview releases';
|
String get optionsUpdateChannelPreview => 'Vorschau-Versionen erhalten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUpdateChannelWarning =>
|
String get optionsUpdateChannelWarning =>
|
||||||
'Preview may contain bugs or incomplete features';
|
'Vorschau kann Fehler oder unvollständige Funktionen enthalten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsClearHistory => 'Clear Download History';
|
String get optionsClearHistory => 'Download-Verlauf löschen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsClearHistorySubtitle =>
|
String get optionsClearHistorySubtitle =>
|
||||||
'Remove all downloaded tracks from history';
|
'Alle heruntergeladenen Titel aus dem Verlauf entfernen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsDetailedLogging => 'Detailed Logging';
|
String get optionsDetailedLogging => 'Detaillierte Protokollierung';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsDetailedLoggingOn => 'Detailed logs are being recorded';
|
String get optionsDetailedLoggingOn =>
|
||||||
|
'Detaillierte Protokolle werden aufgezeichnet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsDetailedLoggingOff => 'Enable for bug reports';
|
String get optionsDetailedLoggingOff => 'Für Fehlerberichte aktivieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSpotifyCredentials => 'Spotify Credentials';
|
String get optionsSpotifyCredentials => 'Spotify-Anmeldedaten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsSpotifyCredentialsConfigured(String clientId) {
|
String optionsSpotifyCredentialsConfigured(String clientId) {
|
||||||
return 'Client ID: $clientId...';
|
return 'Client-ID: $clientId...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSpotifyCredentialsRequired => 'Required - tap to configure';
|
String get optionsSpotifyCredentialsRequired =>
|
||||||
|
'Erforderlich - zum Konfigurieren tippen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Erweiterungen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsInstalled => 'Installed Extensions';
|
String get extensionsInstalled => 'Installierte Erweiterungen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNone => 'No extensions installed';
|
String get extensionsNone => 'Keine Erweiterungen installiert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoneSubtitle => 'Install extensions from the Store tab';
|
String get extensionsNoneSubtitle =>
|
||||||
|
'Erweiterungen aus dem Store-Tab installieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsEnabled => 'Enabled';
|
String get extensionsEnabled => 'Aktiviert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsDisabled => 'Disabled';
|
String get extensionsDisabled => 'Deaktiviert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String extensionsVersion(String version) {
|
String extensionsVersion(String version) {
|
||||||
@@ -362,41 +372,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String extensionsAuthor(String author) {
|
String extensionsAuthor(String author) {
|
||||||
return 'by $author';
|
return 'von $author';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsUninstall => 'Uninstall';
|
String get extensionsUninstall => 'Deinstallieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsSetAsSearch => 'Set as Search Provider';
|
String get extensionsSetAsSearch => 'Als Suchanbieter festlegen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Extension Store';
|
String get storeTitle => 'Erweiterungs-Store';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Search extensions...';
|
String get storeSearch => 'Erweiterungen suchen...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeInstall => 'Install';
|
String get storeInstall => 'Installieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeInstalled => 'Installed';
|
String get storeInstalled => 'Installiert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeUpdate => 'Update';
|
String get storeUpdate => 'Aktualisieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutTitle => 'About';
|
String get aboutTitle => 'Über';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutContributors => 'Contributors';
|
String get aboutContributors => 'Mitwirkende';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
String get aboutMobileDeveloper => 'Mobile-Version Entwickler';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
String get aboutOriginalCreator => 'Schöpfer des ursprünglichen SpotiFLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutLogoArtist =>
|
String get aboutLogoArtist =>
|
||||||
|
|||||||
+127
-127
@@ -16,19 +16,19 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'ホーム';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHistory => 'History';
|
String get navHistory => '履歴';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navSettings => 'Settings';
|
String get navSettings => '設定';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Store';
|
String get navStore => 'ストア';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Home';
|
String get homeTitle => 'ホーム';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
String get homeSearchHint => 'Paste Spotify URL or search...';
|
||||||
@@ -52,20 +52,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String historyDownloading(int count) {
|
String historyDownloading(int count) {
|
||||||
return 'Downloading ($count)';
|
return 'ダウンロード中 ($count)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyDownloaded => 'Downloaded';
|
String get historyDownloaded => 'ダウンロード済み';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAll => 'All';
|
String get historyFilterAll => 'すべて';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAlbums => 'Albums';
|
String get historyFilterAlbums => 'アルバム';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterSingles => 'Singles';
|
String get historyFilterSingles => 'シングル';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String historyTracksCount(int count) {
|
String historyTracksCount(int count) {
|
||||||
@@ -110,25 +110,25 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => '設定';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownload => 'Download';
|
String get settingsDownload => 'ダウンロード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAppearance => 'Appearance';
|
String get settingsAppearance => '外観';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsOptions => 'Options';
|
String get settingsOptions => 'オプション';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsExtensions => 'Extensions';
|
String get settingsExtensions => '拡張';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAbout => 'About';
|
String get settingsAbout => 'アプリについて';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadTitle => 'Download';
|
String get downloadTitle => 'ダウンロード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocation => 'Download Location';
|
String get downloadLocation => 'Download Location';
|
||||||
@@ -137,16 +137,16 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get downloadLocationSubtitle => 'Choose where to save files';
|
String get downloadLocationSubtitle => 'Choose where to save files';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocationDefault => 'Default location';
|
String get downloadLocationDefault => 'デフォルトの場所';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultService => 'Default Service';
|
String get downloadDefaultService => 'デフォルトのサービス';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
|
String get downloadDefaultServiceSubtitle => 'ダウンロードに使用したサービス';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultQuality => 'Default Quality';
|
String get downloadDefaultQuality => 'デフォルトの品質';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskQuality => 'Ask Quality Before Download';
|
String get downloadAskQuality => 'Ask Quality Before Download';
|
||||||
@@ -156,7 +156,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Show quality picker for each download';
|
'Show quality picker for each download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Filename Format';
|
String get downloadFilenameFormat => 'ファイル名の形式';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Folder Organization';
|
String get downloadFolderOrganization => 'Folder Organization';
|
||||||
@@ -181,46 +181,46 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get quality128 => '128 kbps';
|
String get quality128 => '128 kbps';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTitle => 'Appearance';
|
String get appearanceTitle => '外観';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTheme => 'Theme';
|
String get appearanceTheme => 'テーマ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeSystem => 'System';
|
String get appearanceThemeSystem => 'システム';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeLight => 'Light';
|
String get appearanceThemeLight => 'ライト';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeDark => 'Dark';
|
String get appearanceThemeDark => 'ダーク';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColor => 'Dynamic Color';
|
String get appearanceDynamicColor => 'ダイナミックカラー';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceAccentColor => 'Accent Color';
|
String get appearanceAccentColor => 'アクセントカラー';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryView => 'History View';
|
String get appearanceHistoryView => '履歴の表示';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryViewList => 'List';
|
String get appearanceHistoryViewList => 'リスト';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryViewGrid => 'Grid';
|
String get appearanceHistoryViewGrid => 'グリッド';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsTitle => 'Options';
|
String get optionsTitle => 'オプション';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSearchSource => 'Search Source';
|
String get optionsSearchSource => '検索ソース';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsPrimaryProvider => 'Primary Provider';
|
String get optionsPrimaryProvider => 'プライマリーのプロバイダー';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsPrimaryProviderSubtitle =>
|
String get optionsPrimaryProviderSubtitle =>
|
||||||
@@ -228,7 +228,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsUsingExtension(String extensionName) {
|
String optionsUsingExtension(String extensionName) {
|
||||||
return 'Using extension: $extensionName';
|
return '拡張の使用: $extensionName';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -243,23 +243,23 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Try other services if download fails';
|
'Try other services if download fails';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
String get optionsUseExtensionProvidersOff => '内蔵のプロバイダーのみを使用する';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
String get optionsEmbedLyrics => '歌詞を埋め込む';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsEmbedLyricsSubtitle =>
|
String get optionsEmbedLyricsSubtitle =>
|
||||||
'Embed synced lyrics into FLAC files';
|
'Embed synced lyrics into FLAC files';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCover => 'Max Quality Cover';
|
String get optionsMaxQualityCover => '最大品質のカバー';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
@@ -281,26 +281,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Parallel downloads may trigger rate limiting';
|
'Parallel downloads may trigger rate limiting';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Extension Store';
|
String get optionsExtensionStore => '拡張ストア';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdates => 'Check for Updates';
|
String get optionsCheckUpdates => '更新を確認';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsCheckUpdatesSubtitle =>
|
String get optionsCheckUpdatesSubtitle =>
|
||||||
'Notify when new version is available';
|
'Notify when new version is available';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUpdateChannel => 'Update Channel';
|
String get optionsUpdateChannel => '更新チャンネル';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUpdateChannelStable => 'Stable releases only';
|
String get optionsUpdateChannelStable => '安定版リリースのみ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUpdateChannelPreview => 'Get preview releases';
|
String get optionsUpdateChannelPreview => 'プレビューリリースを入手';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUpdateChannelWarning =>
|
String get optionsUpdateChannelWarning =>
|
||||||
@@ -323,11 +323,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get optionsDetailedLoggingOff => 'Enable for bug reports';
|
String get optionsDetailedLoggingOff => 'Enable for bug reports';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSpotifyCredentials => 'Spotify Credentials';
|
String get optionsSpotifyCredentials => 'Spotify の認証情報';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsSpotifyCredentialsConfigured(String clientId) {
|
String optionsSpotifyCredentialsConfigured(String clientId) {
|
||||||
return 'Client ID: $clientId...';
|
return 'クライアント ID: $clientId...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -338,62 +338,62 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => '拡張';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsInstalled => 'Installed Extensions';
|
String get extensionsInstalled => 'インストール済みの拡張';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNone => 'No extensions installed';
|
String get extensionsNone => '拡張はインストールされていません';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoneSubtitle => 'Install extensions from the Store tab';
|
String get extensionsNoneSubtitle => 'ストアタブから拡張をインストール';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsEnabled => 'Enabled';
|
String get extensionsEnabled => '有効';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsDisabled => 'Disabled';
|
String get extensionsDisabled => 'Disabled';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String extensionsVersion(String version) {
|
String extensionsVersion(String version) {
|
||||||
return 'Version $version';
|
return 'バージョン $version';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String extensionsAuthor(String author) {
|
String extensionsAuthor(String author) {
|
||||||
return 'by $author';
|
return '作者 $author';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsUninstall => 'Uninstall';
|
String get extensionsUninstall => 'アンインストール';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsSetAsSearch => 'Set as Search Provider';
|
String get extensionsSetAsSearch => '検索プロバイダーを設定';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Extension Store';
|
String get storeTitle => '拡張ストア';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Search extensions...';
|
String get storeSearch => '拡張を検索...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeInstall => 'Install';
|
String get storeInstall => 'インストール';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeInstalled => 'Installed';
|
String get storeInstalled => 'インストール済み';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeUpdate => 'Update';
|
String get storeUpdate => '更新';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutTitle => 'About';
|
String get aboutTitle => 'アプリについて';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutContributors => 'Contributors';
|
String get aboutContributors => '貢献者';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
String get aboutMobileDeveloper => 'モバイルバージョンの開発者';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
||||||
@@ -403,25 +403,25 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'The talented artist who created our beautiful app logo!';
|
'The talented artist who created our beautiful app logo!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSpecialThanks => 'Special Thanks';
|
String get aboutSpecialThanks => 'スペシャルサンクス';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutLinks => 'Links';
|
String get aboutLinks => 'リンク';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutMobileSource => 'Mobile source code';
|
String get aboutMobileSource => 'モバイル版のソースコード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutPCSource => 'PC source code';
|
String get aboutPCSource => 'PC 版のソースコード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutReportIssue => 'Report an issue';
|
String get aboutReportIssue => 'Issue で報告する';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
|
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutFeatureRequest => 'Feature request';
|
String get aboutFeatureRequest => '機能の要望';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||||
@@ -430,16 +430,16 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'アプリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutVersion => 'Version';
|
String get aboutVersion => 'バージョン';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutBinimumDesc =>
|
String get aboutBinimumDesc =>
|
||||||
@@ -497,10 +497,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get artistAlbums => 'Albums';
|
String get artistAlbums => 'Albums';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistSingles => 'Singles & EPs';
|
String get artistSingles => 'シングルと EP';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistCompilations => 'Compilations';
|
String get artistCompilations => 'コンピレーション';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String artistReleases(int count) {
|
String artistReleases(int count) {
|
||||||
@@ -589,13 +589,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get setupChooseFolder => 'Choose Folder';
|
String get setupChooseFolder => 'Choose Folder';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupContinue => 'Continue';
|
String get setupContinue => '続行';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupSkip => 'Skip for now';
|
String get setupSkip => '今はスキップ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStorageAccessRequired => 'Storage Access Required';
|
String get setupStorageAccessRequired => 'ストレージアクセスが必要です';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStorageAccessMessage =>
|
String get setupStorageAccessMessage =>
|
||||||
@@ -675,7 +675,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get setupStepSpotify => 'Spotify';
|
String get setupStepSpotify => 'Spotify';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStepPermission => 'Permission';
|
String get setupStepPermission => '権限';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStorageGranted => 'Storage Permission Granted!';
|
String get setupStorageGranted => 'Storage Permission Granted!';
|
||||||
@@ -691,14 +691,14 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get setupNotificationGranted => 'Notification Permission Granted!';
|
String get setupNotificationGranted => 'Notification Permission Granted!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNotificationEnable => 'Enable Notifications';
|
String get setupNotificationEnable => '通知を有効化する';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNotificationDescription =>
|
String get setupNotificationDescription =>
|
||||||
'Get notified when downloads complete or require attention.';
|
'Get notified when downloads complete or require attention.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupFolderSelected => 'Download Folder Selected!';
|
String get setupFolderSelected => 'ダウンロードフォルダが選択済みです!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupFolderChoose => 'Choose Download Folder';
|
String get setupFolderChoose => 'Choose Download Folder';
|
||||||
@@ -714,26 +714,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get setupSelectFolder => 'Select Folder';
|
String get setupSelectFolder => 'Select Folder';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupSpotifyApiOptional => 'Spotify API (Optional)';
|
String get setupSpotifyApiOptional => 'Spotify API (任意)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupSpotifyApiDescription =>
|
String get setupSpotifyApiDescription =>
|
||||||
'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.';
|
'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupUseSpotifyApi => 'Use Spotify API';
|
String get setupUseSpotifyApi => 'Spotify API を使用する';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupEnterCredentialsBelow => 'Enter your credentials below';
|
String get setupEnterCredentialsBelow => 'Enter your credentials below';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupUsingDeezer => 'Using Deezer (no account needed)';
|
String get setupUsingDeezer => 'Deezer を使用中 (アカウントは不要です)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupEnterClientId => 'Enter Spotify Client ID';
|
String get setupEnterClientId => 'Spotify クライアント ID を入力';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupEnterClientSecret => 'Enter Spotify Client Secret';
|
String get setupEnterClientSecret => 'Spotify クライアントシークレットを入力';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupGetFreeCredentials =>
|
String get setupGetFreeCredentials =>
|
||||||
@@ -754,19 +754,19 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
|
'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupSkipForNow => 'Skip for now';
|
String get setupSkipForNow => '今はスキップ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupBack => 'Back';
|
String get setupBack => '戻る';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupNext => 'Next';
|
String get setupNext => '次へ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupGetStarted => 'Get Started';
|
String get setupGetStarted => 'Get Started';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupSkipAndStart => 'Skip & Start';
|
String get setupSkipAndStart => 'スキップと開始';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupAllowAccessToManageFiles =>
|
String get setupAllowAccessToManageFiles =>
|
||||||
@@ -858,7 +858,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
'Are you sure you want to remove this extension? This cannot be undone.';
|
'Are you sure you want to remove this extension? This cannot be undone.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogUninstallExtension => 'Uninstall Extension?';
|
String get dialogUninstallExtension => '拡張をアンインストールしますか?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String dialogUninstallExtensionMessage(String extensionName) {
|
String dialogUninstallExtensionMessage(String extensionName) {
|
||||||
@@ -887,7 +887,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogImportPlaylistTitle => 'Import Playlist';
|
String get dialogImportPlaylistTitle => 'プレイリストをインポート';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String dialogImportPlaylistMessage(int count) {
|
String dialogImportPlaylistMessage(int count) {
|
||||||
@@ -980,7 +980,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get snackbarFailedToUpdate => 'Failed to update extension';
|
String get snackbarFailedToUpdate => 'Failed to update extension';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get errorRateLimited => 'Rate Limited';
|
String get errorRateLimited => 'レート制限';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get errorRateLimitedMessage =>
|
String get errorRateLimitedMessage =>
|
||||||
@@ -1178,7 +1178,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get updateDownload => 'Download';
|
String get updateDownload => 'ダウンロード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get updateLater => 'Later';
|
String get updateLater => 'Later';
|
||||||
@@ -1199,7 +1199,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get updateNewVersionReady => 'A new version is ready';
|
String get updateNewVersionReady => 'A new version is ready';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get updateCurrent => 'Current';
|
String get updateCurrent => '現在';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get updateNew => 'New';
|
String get updateNew => 'New';
|
||||||
@@ -1303,13 +1303,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get logClearLogsMessage => 'Are you sure you want to clear all logs?';
|
String get logClearLogsMessage => 'Are you sure you want to clear all logs?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get logIspBlocking => 'ISP BLOCKING DETECTED';
|
String get logIspBlocking => 'ISP のブロックを検出しました';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get logRateLimited => 'RATE LIMITED';
|
String get logRateLimited => 'レート制限';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get logNetworkError => 'NETWORK ERROR';
|
String get logNetworkError => 'ネットワークエラー';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get logTrackNotFound => 'TRACK NOT FOUND';
|
String get logTrackNotFound => 'TRACK NOT FOUND';
|
||||||
@@ -1498,22 +1498,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get trackMetadata => 'Metadata';
|
String get trackMetadata => 'Metadata';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackFileInfo => 'File Info';
|
String get trackFileInfo => 'ファイル情報';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackLyrics => 'Lyrics';
|
String get trackLyrics => '歌詞';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackFileNotFound => 'File not found';
|
String get trackFileNotFound => 'ファイルがありません';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOpenInDeezer => 'Open in Deezer';
|
String get trackOpenInDeezer => 'Deezer で開く';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOpenInSpotify => 'Open in Spotify';
|
String get trackOpenInSpotify => 'Spotify で開く';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackTrackName => 'Track name';
|
String get trackTrackName => 'トラック名';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackArtist => 'Artist';
|
String get trackArtist => 'Artist';
|
||||||
@@ -1636,16 +1636,16 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionAuthor => 'Author';
|
String get extensionAuthor => '作者';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionId => 'ID';
|
String get extensionId => 'ID';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionError => 'Error';
|
String get extensionError => 'エラー';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionCapabilities => 'Capabilities';
|
String get extensionCapabilities => 'Capabilities';
|
||||||
@@ -1675,16 +1675,16 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get extensionSettings => 'Settings';
|
String get extensionSettings => 'Settings';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionRemoveButton => 'Remove Extension';
|
String get extensionRemoveButton => '拡張を削除';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionUpdated => 'Updated';
|
String get extensionUpdated => '更新済み';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionMinAppVersion => 'Min App Version';
|
String get extensionMinAppVersion => '最小のアプリバージョン';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionCustomTrackMatching => 'Custom Track Matching';
|
String get extensionCustomTrackMatching => 'カスタムトラックマッチング';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionPostProcessing => 'Post-Processing';
|
String get extensionPostProcessing => 'Post-Processing';
|
||||||
@@ -1708,17 +1708,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get extensionsProviderPrioritySection => 'Provider Priority';
|
String get extensionsProviderPrioritySection => 'Provider Priority';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsInstalledSection => 'Installed Extensions';
|
String get extensionsInstalledSection => 'インストール済みの拡張';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoExtensions => 'No extensions installed';
|
String get extensionsNoExtensions => '拡張はインストールされていません';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsNoExtensionsSubtitle =>
|
String get extensionsNoExtensionsSubtitle =>
|
||||||
'Install .spotiflac-ext files to add new providers';
|
'Install .spotiflac-ext files to add new providers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsInstallButton => 'Install Extension';
|
String get extensionsInstallButton => '拡張をインストール';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsInfoTip =>
|
String get extensionsInfoTip =>
|
||||||
@@ -1765,22 +1765,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get extensionsErrorLoading => 'Error loading extension';
|
String get extensionsErrorLoading => 'Error loading extension';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityFlacLossless => 'FLAC Lossless';
|
String get qualityFlacLossless => 'FLAC ロスレス';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz';
|
String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityHiResFlac => 'Hi-Res FLAC';
|
String get qualityHiResFlac => 'ハイレゾ FLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz';
|
String get qualityHiResFlacSubtitle => '24-bit / 最大 96kHz';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMax => 'Hi-Res FLAC Max';
|
String get qualityHiResFlacMax => 'ハイレゾ FLAC 最大';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
@@ -1790,10 +1790,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDirectory => 'Download Directory';
|
String get downloadDirectory => 'ダウンロードディレクトリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSeparateSinglesFolder => 'Separate Singles Folder';
|
String get downloadSeparateSinglesFolder => 'シングルのフォルダを分割';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
@@ -1856,22 +1856,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get serviceSpotify => 'Spotify';
|
String get serviceSpotify => 'Spotify';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceAmoledDark => 'AMOLED Dark';
|
String get appearanceAmoledDark => 'AMOLED ダーク';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceAmoledDarkSubtitle => 'Pure black background';
|
String get appearanceAmoledDarkSubtitle => 'ピュアブラックの背景';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceChooseAccentColor => 'Choose Accent Color';
|
String get appearanceChooseAccentColor => 'Choose Accent Color';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceChooseTheme => 'Theme Mode';
|
String get appearanceChooseTheme => 'テーマモード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueTitle => 'Download Queue';
|
String get queueTitle => 'ダウンロードキュー';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueClearAll => 'Clear All';
|
String get queueClearAll => 'すべて消去';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
|
|||||||
+629
-584
File diff suppressed because it is too large
Load Diff
@@ -2508,6 +2508,14 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -3962,6 +3970,28 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
String get utilityFunctions => 'Utility Functions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentTypeArtist => 'Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentTypeAlbum => 'Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentTypeSong => 'Song';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recentPlaylistInfo(String name) {
|
||||||
|
return 'Playlist: $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorGeneric(String message) {
|
||||||
|
return 'Error: $message';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
|
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
|
||||||
@@ -4473,6 +4503,14 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
|||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get artistPopular => 'Popular';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String artistMonthlyListeners(String count) {
|
||||||
|
return '$count monthly listeners';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataTitle => 'Track Info';
|
String get trackMetadataTitle => 'Track Info';
|
||||||
|
|
||||||
@@ -5388,6 +5426,15 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
|||||||
@override
|
@override
|
||||||
String get sectionLayout => 'Layout';
|
String get sectionLayout => 'Layout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionLanguage => 'Language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceLanguage => 'App Language';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceLanguageSubtitle => 'Choose your preferred language';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAppearanceSubtitle => 'Theme, colors, display';
|
String get settingsAppearanceSubtitle => 'Theme, colors, display';
|
||||||
|
|
||||||
@@ -5918,4 +5965,26 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
String get utilityFunctions => 'Utility Functions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentTypeArtist => 'Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentTypeAlbum => 'Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentTypeSong => 'Song';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recentPlaylistInfo(String name) {
|
||||||
|
return 'Playlist: $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String errorGeneric(String message) {
|
||||||
|
return 'Error: $message';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-106
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "de",
|
"@@locale": "de",
|
||||||
"@@last_modified": "2026-01-16",
|
"@@last_modified": "2026-01-17",
|
||||||
"appName": "SpotiFLAC",
|
"appName": "SpotiFLAC",
|
||||||
"@appName": {
|
"@appName": {
|
||||||
"description": "App name - DO NOT TRANSLATE"
|
"description": "App name - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"appDescription": "Laden Sie Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
|
||||||
"@appDescription": {
|
"@appDescription": {
|
||||||
"description": "App description shown in about page"
|
"description": "App description shown in about page"
|
||||||
},
|
},
|
||||||
"navHome": "Home",
|
"navHome": "Startseite",
|
||||||
"@navHome": {
|
"@navHome": {
|
||||||
"description": "Bottom navigation - Home tab"
|
"description": "Bottom navigation - Home tab"
|
||||||
},
|
},
|
||||||
"navHistory": "History",
|
"navHistory": "Verlauf",
|
||||||
"@navHistory": {
|
"@navHistory": {
|
||||||
"description": "Bottom navigation - History tab"
|
"description": "Bottom navigation - History tab"
|
||||||
},
|
},
|
||||||
"navSettings": "Settings",
|
"navSettings": "Einstellungen",
|
||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
@@ -25,15 +25,15 @@
|
|||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
"homeTitle": "Home",
|
"homeTitle": "Startseite",
|
||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSearchHint": "Paste Spotify URL or search...",
|
"homeSearchHint": "Spotify-URL einfügen oder suchen...",
|
||||||
"@homeSearchHint": {
|
"@homeSearchHint": {
|
||||||
"description": "Placeholder text in search box"
|
"description": "Placeholder text in search box"
|
||||||
},
|
},
|
||||||
"homeSearchHintExtension": "Search with {extensionName}...",
|
"homeSearchHintExtension": "Mit {extensionName} suchen...",
|
||||||
"@homeSearchHintExtension": {
|
"@homeSearchHintExtension": {
|
||||||
"description": "Placeholder when extension search is active",
|
"description": "Placeholder when extension search is active",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -43,23 +43,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"homeSubtitle": "Paste a Spotify link or search by name",
|
"homeSubtitle": "Spotify-Link einfügen oder nach Namen suchen",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
|
"homeSupports": "Unterstützt: Titel, Album, Playlist, Künstler-URLs",
|
||||||
"@homeSupports": {
|
"@homeSupports": {
|
||||||
"description": "Info text about supported URL types"
|
"description": "Info text about supported URL types"
|
||||||
},
|
},
|
||||||
"homeRecent": "Recent",
|
"homeRecent": "Zuletzt",
|
||||||
"@homeRecent": {
|
"@homeRecent": {
|
||||||
"description": "Section header for recent searches"
|
"description": "Section header for recent searches"
|
||||||
},
|
},
|
||||||
"historyTitle": "History",
|
"historyTitle": "Verlauf",
|
||||||
"@historyTitle": {
|
"@historyTitle": {
|
||||||
"description": "History screen title"
|
"description": "History screen title"
|
||||||
},
|
},
|
||||||
"historyDownloading": "Downloading ({count})",
|
"historyDownloading": "Wird heruntergeladen ({count})",
|
||||||
"@historyDownloading": {
|
"@historyDownloading": {
|
||||||
"description": "Tab showing active downloads count",
|
"description": "Tab showing active downloads count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -69,15 +69,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"historyDownloaded": "Downloaded",
|
"historyDownloaded": "Heruntergeladen",
|
||||||
"@historyDownloaded": {
|
"@historyDownloaded": {
|
||||||
"description": "Tab showing completed downloads"
|
"description": "Tab showing completed downloads"
|
||||||
},
|
},
|
||||||
"historyFilterAll": "All",
|
"historyFilterAll": "Alle",
|
||||||
"@historyFilterAll": {
|
"@historyFilterAll": {
|
||||||
"description": "Filter chip - show all items"
|
"description": "Filter chip - show all items"
|
||||||
},
|
},
|
||||||
"historyFilterAlbums": "Albums",
|
"historyFilterAlbums": "Alben",
|
||||||
"@historyFilterAlbums": {
|
"@historyFilterAlbums": {
|
||||||
"description": "Filter chip - show albums only"
|
"description": "Filter chip - show albums only"
|
||||||
},
|
},
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"@historyFilterSingles": {
|
"@historyFilterSingles": {
|
||||||
"description": "Filter chip - show singles only"
|
"description": "Filter chip - show singles only"
|
||||||
},
|
},
|
||||||
"historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}",
|
"historyTracksCount": "{count, plural, =1{1 Titel} other{{count} Titel}}",
|
||||||
"@historyTracksCount": {
|
"@historyTracksCount": {
|
||||||
"description": "Track count with plural form",
|
"description": "Track count with plural form",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}",
|
"historyAlbumsCount": "{count, plural, =1{1 Album} other{{count} Alben}}",
|
||||||
"@historyAlbumsCount": {
|
"@historyAlbumsCount": {
|
||||||
"description": "Album count with plural form",
|
"description": "Album count with plural form",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -103,31 +103,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"historyNoDownloads": "No download history",
|
"historyNoDownloads": "Kein Download-Verlauf",
|
||||||
"@historyNoDownloads": {
|
"@historyNoDownloads": {
|
||||||
"description": "Empty state title"
|
"description": "Empty state title"
|
||||||
},
|
},
|
||||||
"historyNoDownloadsSubtitle": "Downloaded tracks will appear here",
|
"historyNoDownloadsSubtitle": "Heruntergeladene Titel werden hier angezeigt",
|
||||||
"@historyNoDownloadsSubtitle": {
|
"@historyNoDownloadsSubtitle": {
|
||||||
"description": "Empty state subtitle"
|
"description": "Empty state subtitle"
|
||||||
},
|
},
|
||||||
"historyNoAlbums": "No album downloads",
|
"historyNoAlbums": "Keine Album-Downloads",
|
||||||
"@historyNoAlbums": {
|
"@historyNoAlbums": {
|
||||||
"description": "Empty state when filtering albums"
|
"description": "Empty state when filtering albums"
|
||||||
},
|
},
|
||||||
"historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here",
|
"historyNoAlbumsSubtitle": "Laden Sie mehrere Titel eines Albums herunter, um sie hier zu sehen",
|
||||||
"@historyNoAlbumsSubtitle": {
|
"@historyNoAlbumsSubtitle": {
|
||||||
"description": "Empty state subtitle for albums filter"
|
"description": "Empty state subtitle for albums filter"
|
||||||
},
|
},
|
||||||
"historyNoSingles": "No single downloads",
|
"historyNoSingles": "Keine Einzel-Downloads",
|
||||||
"@historyNoSingles": {
|
"@historyNoSingles": {
|
||||||
"description": "Empty state when filtering singles"
|
"description": "Empty state when filtering singles"
|
||||||
},
|
},
|
||||||
"historyNoSinglesSubtitle": "Single track downloads will appear here",
|
"historyNoSinglesSubtitle": "Einzelne Titel-Downloads werden hier angezeigt",
|
||||||
"@historyNoSinglesSubtitle": {
|
"@historyNoSinglesSubtitle": {
|
||||||
"description": "Empty state subtitle for singles filter"
|
"description": "Empty state subtitle for singles filter"
|
||||||
},
|
},
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Einstellungen",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
},
|
},
|
||||||
@@ -135,19 +135,19 @@
|
|||||||
"@settingsDownload": {
|
"@settingsDownload": {
|
||||||
"description": "Settings section - download options"
|
"description": "Settings section - download options"
|
||||||
},
|
},
|
||||||
"settingsAppearance": "Appearance",
|
"settingsAppearance": "Erscheinungsbild",
|
||||||
"@settingsAppearance": {
|
"@settingsAppearance": {
|
||||||
"description": "Settings section - visual customization"
|
"description": "Settings section - visual customization"
|
||||||
},
|
},
|
||||||
"settingsOptions": "Options",
|
"settingsOptions": "Optionen",
|
||||||
"@settingsOptions": {
|
"@settingsOptions": {
|
||||||
"description": "Settings section - app options"
|
"description": "Settings section - app options"
|
||||||
},
|
},
|
||||||
"settingsExtensions": "Extensions",
|
"settingsExtensions": "Erweiterungen",
|
||||||
"@settingsExtensions": {
|
"@settingsExtensions": {
|
||||||
"description": "Settings section - extension management"
|
"description": "Settings section - extension management"
|
||||||
},
|
},
|
||||||
"settingsAbout": "About",
|
"settingsAbout": "Über",
|
||||||
"@settingsAbout": {
|
"@settingsAbout": {
|
||||||
"description": "Settings section - app info"
|
"description": "Settings section - app info"
|
||||||
},
|
},
|
||||||
@@ -155,55 +155,55 @@
|
|||||||
"@downloadTitle": {
|
"@downloadTitle": {
|
||||||
"description": "Download settings page title"
|
"description": "Download settings page title"
|
||||||
},
|
},
|
||||||
"downloadLocation": "Download Location",
|
"downloadLocation": "Download-Speicherort",
|
||||||
"@downloadLocation": {
|
"@downloadLocation": {
|
||||||
"description": "Setting for download folder"
|
"description": "Setting for download folder"
|
||||||
},
|
},
|
||||||
"downloadLocationSubtitle": "Choose where to save files",
|
"downloadLocationSubtitle": "Wählen Sie den Speicherort für Dateien",
|
||||||
"@downloadLocationSubtitle": {
|
"@downloadLocationSubtitle": {
|
||||||
"description": "Subtitle for download location"
|
"description": "Subtitle for download location"
|
||||||
},
|
},
|
||||||
"downloadLocationDefault": "Default location",
|
"downloadLocationDefault": "Standard-Speicherort",
|
||||||
"@downloadLocationDefault": {
|
"@downloadLocationDefault": {
|
||||||
"description": "Shown when using default folder"
|
"description": "Shown when using default folder"
|
||||||
},
|
},
|
||||||
"downloadDefaultService": "Default Service",
|
"downloadDefaultService": "Standard-Dienst",
|
||||||
"@downloadDefaultService": {
|
"@downloadDefaultService": {
|
||||||
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
|
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"downloadDefaultServiceSubtitle": "Service used for downloads",
|
"downloadDefaultServiceSubtitle": "Dienst für Downloads",
|
||||||
"@downloadDefaultServiceSubtitle": {
|
"@downloadDefaultServiceSubtitle": {
|
||||||
"description": "Subtitle for default service"
|
"description": "Subtitle for default service"
|
||||||
},
|
},
|
||||||
"downloadDefaultQuality": "Default Quality",
|
"downloadDefaultQuality": "Standard-Qualität",
|
||||||
"@downloadDefaultQuality": {
|
"@downloadDefaultQuality": {
|
||||||
"description": "Setting for audio quality"
|
"description": "Setting for audio quality"
|
||||||
},
|
},
|
||||||
"downloadAskQuality": "Ask Quality Before Download",
|
"downloadAskQuality": "Qualität vor Download abfragen",
|
||||||
"@downloadAskQuality": {
|
"@downloadAskQuality": {
|
||||||
"description": "Toggle to show quality picker"
|
"description": "Toggle to show quality picker"
|
||||||
},
|
},
|
||||||
"downloadAskQualitySubtitle": "Show quality picker for each download",
|
"downloadAskQualitySubtitle": "Qualitätsauswahl für jeden Download anzeigen",
|
||||||
"@downloadAskQualitySubtitle": {
|
"@downloadAskQualitySubtitle": {
|
||||||
"description": "Subtitle for ask quality toggle"
|
"description": "Subtitle for ask quality toggle"
|
||||||
},
|
},
|
||||||
"downloadFilenameFormat": "Filename Format",
|
"downloadFilenameFormat": "Dateinamenformat",
|
||||||
"@downloadFilenameFormat": {
|
"@downloadFilenameFormat": {
|
||||||
"description": "Setting for output filename pattern"
|
"description": "Setting for output filename pattern"
|
||||||
},
|
},
|
||||||
"downloadFolderOrganization": "Folder Organization",
|
"downloadFolderOrganization": "Ordnerstruktur",
|
||||||
"@downloadFolderOrganization": {
|
"@downloadFolderOrganization": {
|
||||||
"description": "Setting for folder structure"
|
"description": "Setting for folder structure"
|
||||||
},
|
},
|
||||||
"downloadSeparateSingles": "Separate Singles",
|
"downloadSeparateSingles": "Singles trennen",
|
||||||
"@downloadSeparateSingles": {
|
"@downloadSeparateSingles": {
|
||||||
"description": "Toggle to separate single tracks"
|
"description": "Toggle to separate single tracks"
|
||||||
},
|
},
|
||||||
"downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder",
|
"downloadSeparateSinglesSubtitle": "Einzelne Titel in separatem Ordner speichern",
|
||||||
"@downloadSeparateSinglesSubtitle": {
|
"@downloadSeparateSinglesSubtitle": {
|
||||||
"description": "Subtitle for separate singles toggle"
|
"description": "Subtitle for separate singles toggle"
|
||||||
},
|
},
|
||||||
"qualityBest": "Best Available",
|
"qualityBest": "Beste Qualität",
|
||||||
"@qualityBest": {
|
"@qualityBest": {
|
||||||
"description": "Audio quality option - highest available"
|
"description": "Audio quality option - highest available"
|
||||||
},
|
},
|
||||||
@@ -219,11 +219,11 @@
|
|||||||
"@quality128": {
|
"@quality128": {
|
||||||
"description": "Audio quality option - 128kbps MP3"
|
"description": "Audio quality option - 128kbps MP3"
|
||||||
},
|
},
|
||||||
"appearanceTitle": "Appearance",
|
"appearanceTitle": "Erscheinungsbild",
|
||||||
"@appearanceTitle": {
|
"@appearanceTitle": {
|
||||||
"description": "Appearance settings page title"
|
"description": "Appearance settings page title"
|
||||||
},
|
},
|
||||||
"appearanceTheme": "Theme",
|
"appearanceTheme": "Design",
|
||||||
"@appearanceTheme": {
|
"@appearanceTheme": {
|
||||||
"description": "Theme mode setting"
|
"description": "Theme mode setting"
|
||||||
},
|
},
|
||||||
@@ -231,55 +231,55 @@
|
|||||||
"@appearanceThemeSystem": {
|
"@appearanceThemeSystem": {
|
||||||
"description": "Follow system theme"
|
"description": "Follow system theme"
|
||||||
},
|
},
|
||||||
"appearanceThemeLight": "Light",
|
"appearanceThemeLight": "Hell",
|
||||||
"@appearanceThemeLight": {
|
"@appearanceThemeLight": {
|
||||||
"description": "Light theme"
|
"description": "Light theme"
|
||||||
},
|
},
|
||||||
"appearanceThemeDark": "Dark",
|
"appearanceThemeDark": "Dunkel",
|
||||||
"@appearanceThemeDark": {
|
"@appearanceThemeDark": {
|
||||||
"description": "Dark theme"
|
"description": "Dark theme"
|
||||||
},
|
},
|
||||||
"appearanceDynamicColor": "Dynamic Color",
|
"appearanceDynamicColor": "Dynamische Farben",
|
||||||
"@appearanceDynamicColor": {
|
"@appearanceDynamicColor": {
|
||||||
"description": "Material You dynamic colors"
|
"description": "Material You dynamic colors"
|
||||||
},
|
},
|
||||||
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
|
"appearanceDynamicColorSubtitle": "Farben von Ihrem Hintergrundbild verwenden",
|
||||||
"@appearanceDynamicColorSubtitle": {
|
"@appearanceDynamicColorSubtitle": {
|
||||||
"description": "Subtitle for dynamic color"
|
"description": "Subtitle for dynamic color"
|
||||||
},
|
},
|
||||||
"appearanceAccentColor": "Accent Color",
|
"appearanceAccentColor": "Akzentfarbe",
|
||||||
"@appearanceAccentColor": {
|
"@appearanceAccentColor": {
|
||||||
"description": "Custom accent color picker"
|
"description": "Custom accent color picker"
|
||||||
},
|
},
|
||||||
"appearanceHistoryView": "History View",
|
"appearanceHistoryView": "Verlaufsansicht",
|
||||||
"@appearanceHistoryView": {
|
"@appearanceHistoryView": {
|
||||||
"description": "Layout style for history"
|
"description": "Layout style for history"
|
||||||
},
|
},
|
||||||
"appearanceHistoryViewList": "List",
|
"appearanceHistoryViewList": "Liste",
|
||||||
"@appearanceHistoryViewList": {
|
"@appearanceHistoryViewList": {
|
||||||
"description": "List layout option"
|
"description": "List layout option"
|
||||||
},
|
},
|
||||||
"appearanceHistoryViewGrid": "Grid",
|
"appearanceHistoryViewGrid": "Raster",
|
||||||
"@appearanceHistoryViewGrid": {
|
"@appearanceHistoryViewGrid": {
|
||||||
"description": "Grid layout option"
|
"description": "Grid layout option"
|
||||||
},
|
},
|
||||||
"optionsTitle": "Options",
|
"optionsTitle": "Optionen",
|
||||||
"@optionsTitle": {
|
"@optionsTitle": {
|
||||||
"description": "Options settings page title"
|
"description": "Options settings page title"
|
||||||
},
|
},
|
||||||
"optionsSearchSource": "Search Source",
|
"optionsSearchSource": "Suchquelle",
|
||||||
"@optionsSearchSource": {
|
"@optionsSearchSource": {
|
||||||
"description": "Section for search provider settings"
|
"description": "Section for search provider settings"
|
||||||
},
|
},
|
||||||
"optionsPrimaryProvider": "Primary Provider",
|
"optionsPrimaryProvider": "Primärer Anbieter",
|
||||||
"@optionsPrimaryProvider": {
|
"@optionsPrimaryProvider": {
|
||||||
"description": "Main search provider setting"
|
"description": "Main search provider setting"
|
||||||
},
|
},
|
||||||
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
|
"optionsPrimaryProviderSubtitle": "Dienst für die Suche nach Titelnamen.",
|
||||||
"@optionsPrimaryProviderSubtitle": {
|
"@optionsPrimaryProviderSubtitle": {
|
||||||
"description": "Subtitle for primary provider"
|
"description": "Subtitle for primary provider"
|
||||||
},
|
},
|
||||||
"optionsUsingExtension": "Using extension: {extensionName}",
|
"optionsUsingExtension": "Erweiterung verwenden: {extensionName}",
|
||||||
"@optionsUsingExtension": {
|
"@optionsUsingExtension": {
|
||||||
"description": "Shows active extension name",
|
"description": "Shows active extension name",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -288,55 +288,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
"optionsSwitchBack": "Tippen Sie auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln",
|
||||||
"@optionsSwitchBack": {
|
"@optionsSwitchBack": {
|
||||||
"description": "Hint to switch back to built-in providers"
|
"description": "Hint to switch back to built-in providers"
|
||||||
},
|
},
|
||||||
"optionsAutoFallback": "Auto Fallback",
|
"optionsAutoFallback": "Automatischer Fallback",
|
||||||
"@optionsAutoFallback": {
|
"@optionsAutoFallback": {
|
||||||
"description": "Auto-retry with other services"
|
"description": "Auto-retry with other services"
|
||||||
},
|
},
|
||||||
"optionsAutoFallbackSubtitle": "Try other services if download fails",
|
"optionsAutoFallbackSubtitle": "Andere Dienste versuchen, wenn Download fehlschlägt",
|
||||||
"@optionsAutoFallbackSubtitle": {
|
"@optionsAutoFallbackSubtitle": {
|
||||||
"description": "Subtitle for auto fallback"
|
"description": "Subtitle for auto fallback"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
"optionsUseExtensionProviders": "Erweiterungs-Anbieter verwenden",
|
||||||
"@optionsUseExtensionProviders": {
|
"@optionsUseExtensionProviders": {
|
||||||
"description": "Enable extension download providers"
|
"description": "Enable extension download providers"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
"optionsUseExtensionProvidersOn": "Erweiterungen werden zuerst versucht",
|
||||||
"@optionsUseExtensionProvidersOn": {
|
"@optionsUseExtensionProvidersOn": {
|
||||||
"description": "Status when extension providers enabled"
|
"description": "Status when extension providers enabled"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
"optionsUseExtensionProvidersOff": "Nur integrierte Anbieter verwenden",
|
||||||
"@optionsUseExtensionProvidersOff": {
|
"@optionsUseExtensionProvidersOff": {
|
||||||
"description": "Status when extension providers disabled"
|
"description": "Status when extension providers disabled"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyrics": "Embed Lyrics",
|
"optionsEmbedLyrics": "Liedtexte einbetten",
|
||||||
"@optionsEmbedLyrics": {
|
"@optionsEmbedLyrics": {
|
||||||
"description": "Embed lyrics in audio files"
|
"description": "Embed lyrics in audio files"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
|
"optionsEmbedLyricsSubtitle": "Synchronisierte Liedtexte in FLAC-Dateien einbetten",
|
||||||
"@optionsEmbedLyricsSubtitle": {
|
"@optionsEmbedLyricsSubtitle": {
|
||||||
"description": "Subtitle for embed lyrics"
|
"description": "Subtitle for embed lyrics"
|
||||||
},
|
},
|
||||||
"optionsMaxQualityCover": "Max Quality Cover",
|
"optionsMaxQualityCover": "Maximale Cover-Qualität",
|
||||||
"@optionsMaxQualityCover": {
|
"@optionsMaxQualityCover": {
|
||||||
"description": "Download highest quality album art"
|
"description": "Download highest quality album art"
|
||||||
},
|
},
|
||||||
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
|
"optionsMaxQualityCoverSubtitle": "Cover in höchster Auflösung herunterladen",
|
||||||
"@optionsMaxQualityCoverSubtitle": {
|
"@optionsMaxQualityCoverSubtitle": {
|
||||||
"description": "Subtitle for max quality cover"
|
"description": "Subtitle for max quality cover"
|
||||||
},
|
},
|
||||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
"optionsConcurrentDownloads": "Parallele Downloads",
|
||||||
"@optionsConcurrentDownloads": {
|
"@optionsConcurrentDownloads": {
|
||||||
"description": "Number of parallel downloads"
|
"description": "Number of parallel downloads"
|
||||||
},
|
},
|
||||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
"optionsConcurrentSequential": "Sequentiell (1 gleichzeitig)",
|
||||||
"@optionsConcurrentSequential": {
|
"@optionsConcurrentSequential": {
|
||||||
"description": "Download one at a time"
|
"description": "Download one at a time"
|
||||||
},
|
},
|
||||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
"optionsConcurrentParallel": "{count} parallele Downloads",
|
||||||
"@optionsConcurrentParallel": {
|
"@optionsConcurrentParallel": {
|
||||||
"description": "Multiple parallel downloads",
|
"description": "Multiple parallel downloads",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -345,67 +345,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
"optionsConcurrentWarning": "Parallele Downloads können Ratenlimitierung auslösen",
|
||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Extension Store",
|
"optionsExtensionStore": "Erweiterungs-Store",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
"optionsExtensionStoreSubtitle": "Store-Tab in Navigation anzeigen",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
"optionsCheckUpdates": "Check for Updates",
|
"optionsCheckUpdates": "Nach Updates suchen",
|
||||||
"@optionsCheckUpdates": {
|
"@optionsCheckUpdates": {
|
||||||
"description": "Auto update check toggle"
|
"description": "Auto update check toggle"
|
||||||
},
|
},
|
||||||
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
|
"optionsCheckUpdatesSubtitle": "Benachrichtigen, wenn neue Version verfügbar",
|
||||||
"@optionsCheckUpdatesSubtitle": {
|
"@optionsCheckUpdatesSubtitle": {
|
||||||
"description": "Subtitle for update check"
|
"description": "Subtitle for update check"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannel": "Update Channel",
|
"optionsUpdateChannel": "Update-Kanal",
|
||||||
"@optionsUpdateChannel": {
|
"@optionsUpdateChannel": {
|
||||||
"description": "Stable vs preview releases"
|
"description": "Stable vs preview releases"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelStable": "Stable releases only",
|
"optionsUpdateChannelStable": "Nur stabile Versionen",
|
||||||
"@optionsUpdateChannelStable": {
|
"@optionsUpdateChannelStable": {
|
||||||
"description": "Only stable updates"
|
"description": "Only stable updates"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelPreview": "Get preview releases",
|
"optionsUpdateChannelPreview": "Vorschau-Versionen erhalten",
|
||||||
"@optionsUpdateChannelPreview": {
|
"@optionsUpdateChannelPreview": {
|
||||||
"description": "Include beta/preview updates"
|
"description": "Include beta/preview updates"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
|
"optionsUpdateChannelWarning": "Vorschau kann Fehler oder unvollständige Funktionen enthalten",
|
||||||
"@optionsUpdateChannelWarning": {
|
"@optionsUpdateChannelWarning": {
|
||||||
"description": "Warning about preview channel"
|
"description": "Warning about preview channel"
|
||||||
},
|
},
|
||||||
"optionsClearHistory": "Clear Download History",
|
"optionsClearHistory": "Download-Verlauf löschen",
|
||||||
"@optionsClearHistory": {
|
"@optionsClearHistory": {
|
||||||
"description": "Delete all download history"
|
"description": "Delete all download history"
|
||||||
},
|
},
|
||||||
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
|
"optionsClearHistorySubtitle": "Alle heruntergeladenen Titel aus dem Verlauf entfernen",
|
||||||
"@optionsClearHistorySubtitle": {
|
"@optionsClearHistorySubtitle": {
|
||||||
"description": "Subtitle for clear history"
|
"description": "Subtitle for clear history"
|
||||||
},
|
},
|
||||||
"optionsDetailedLogging": "Detailed Logging",
|
"optionsDetailedLogging": "Detaillierte Protokollierung",
|
||||||
"@optionsDetailedLogging": {
|
"@optionsDetailedLogging": {
|
||||||
"description": "Enable verbose logs for debugging"
|
"description": "Enable verbose logs for debugging"
|
||||||
},
|
},
|
||||||
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
|
"optionsDetailedLoggingOn": "Detaillierte Protokolle werden aufgezeichnet",
|
||||||
"@optionsDetailedLoggingOn": {
|
"@optionsDetailedLoggingOn": {
|
||||||
"description": "Status when logging enabled"
|
"description": "Status when logging enabled"
|
||||||
},
|
},
|
||||||
"optionsDetailedLoggingOff": "Enable for bug reports",
|
"optionsDetailedLoggingOff": "Für Fehlerberichte aktivieren",
|
||||||
"@optionsDetailedLoggingOff": {
|
"@optionsDetailedLoggingOff": {
|
||||||
"description": "Status when logging disabled"
|
"description": "Status when logging disabled"
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentials": "Spotify Credentials",
|
"optionsSpotifyCredentials": "Spotify-Anmeldedaten",
|
||||||
"@optionsSpotifyCredentials": {
|
"@optionsSpotifyCredentials": {
|
||||||
"description": "Spotify API credentials setting"
|
"description": "Spotify API credentials setting"
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
|
"optionsSpotifyCredentialsConfigured": "Client-ID: {clientId}...",
|
||||||
"@optionsSpotifyCredentialsConfigured": {
|
"@optionsSpotifyCredentialsConfigured": {
|
||||||
"description": "Shows configured client ID preview",
|
"description": "Shows configured client ID preview",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -414,35 +414,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
|
"optionsSpotifyCredentialsRequired": "Erforderlich - zum Konfigurieren tippen",
|
||||||
"@optionsSpotifyCredentialsRequired": {
|
"@optionsSpotifyCredentialsRequired": {
|
||||||
"description": "Prompt to set up credentials"
|
"description": "Prompt to set up credentials"
|
||||||
},
|
},
|
||||||
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
|
"optionsSpotifyWarning": "Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com",
|
||||||
"@optionsSpotifyWarning": {
|
"@optionsSpotifyWarning": {
|
||||||
"description": "Info about Spotify API requirement"
|
"description": "Info about Spotify API requirement"
|
||||||
},
|
},
|
||||||
"extensionsTitle": "Extensions",
|
"extensionsTitle": "Erweiterungen",
|
||||||
"@extensionsTitle": {
|
"@extensionsTitle": {
|
||||||
"description": "Extensions page title"
|
"description": "Extensions page title"
|
||||||
},
|
},
|
||||||
"extensionsInstalled": "Installed Extensions",
|
"extensionsInstalled": "Installierte Erweiterungen",
|
||||||
"@extensionsInstalled": {
|
"@extensionsInstalled": {
|
||||||
"description": "Section header for installed extensions"
|
"description": "Section header for installed extensions"
|
||||||
},
|
},
|
||||||
"extensionsNone": "No extensions installed",
|
"extensionsNone": "Keine Erweiterungen installiert",
|
||||||
"@extensionsNone": {
|
"@extensionsNone": {
|
||||||
"description": "Empty state title"
|
"description": "Empty state title"
|
||||||
},
|
},
|
||||||
"extensionsNoneSubtitle": "Install extensions from the Store tab",
|
"extensionsNoneSubtitle": "Erweiterungen aus dem Store-Tab installieren",
|
||||||
"@extensionsNoneSubtitle": {
|
"@extensionsNoneSubtitle": {
|
||||||
"description": "Empty state subtitle"
|
"description": "Empty state subtitle"
|
||||||
},
|
},
|
||||||
"extensionsEnabled": "Enabled",
|
"extensionsEnabled": "Aktiviert",
|
||||||
"@extensionsEnabled": {
|
"@extensionsEnabled": {
|
||||||
"description": "Extension status - active"
|
"description": "Extension status - active"
|
||||||
},
|
},
|
||||||
"extensionsDisabled": "Disabled",
|
"extensionsDisabled": "Deaktiviert",
|
||||||
"@extensionsDisabled": {
|
"@extensionsDisabled": {
|
||||||
"description": "Extension status - inactive"
|
"description": "Extension status - inactive"
|
||||||
},
|
},
|
||||||
@@ -455,7 +455,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensionsAuthor": "by {author}",
|
"extensionsAuthor": "von {author}",
|
||||||
"@extensionsAuthor": {
|
"@extensionsAuthor": {
|
||||||
"description": "Extension author credit",
|
"description": "Extension author credit",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -464,47 +464,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensionsUninstall": "Uninstall",
|
"extensionsUninstall": "Deinstallieren",
|
||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"extensionsSetAsSearch": "Set as Search Provider",
|
"extensionsSetAsSearch": "Als Suchanbieter festlegen",
|
||||||
"@extensionsSetAsSearch": {
|
"@extensionsSetAsSearch": {
|
||||||
"description": "Use extension for search"
|
"description": "Use extension for search"
|
||||||
},
|
},
|
||||||
"storeTitle": "Extension Store",
|
"storeTitle": "Erweiterungs-Store",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
"storeSearch": "Search extensions...",
|
"storeSearch": "Erweiterungen suchen...",
|
||||||
"@storeSearch": {
|
"@storeSearch": {
|
||||||
"description": "Store search placeholder"
|
"description": "Store search placeholder"
|
||||||
},
|
},
|
||||||
"storeInstall": "Install",
|
"storeInstall": "Installieren",
|
||||||
"@storeInstall": {
|
"@storeInstall": {
|
||||||
"description": "Install extension button"
|
"description": "Install extension button"
|
||||||
},
|
},
|
||||||
"storeInstalled": "Installed",
|
"storeInstalled": "Installiert",
|
||||||
"@storeInstalled": {
|
"@storeInstalled": {
|
||||||
"description": "Already installed badge"
|
"description": "Already installed badge"
|
||||||
},
|
},
|
||||||
"storeUpdate": "Update",
|
"storeUpdate": "Aktualisieren",
|
||||||
"@storeUpdate": {
|
"@storeUpdate": {
|
||||||
"description": "Update available button"
|
"description": "Update available button"
|
||||||
},
|
},
|
||||||
"aboutTitle": "About",
|
"aboutTitle": "Über",
|
||||||
"@aboutTitle": {
|
"@aboutTitle": {
|
||||||
"description": "About page title"
|
"description": "About page title"
|
||||||
},
|
},
|
||||||
"aboutContributors": "Contributors",
|
"aboutContributors": "Mitwirkende",
|
||||||
"@aboutContributors": {
|
"@aboutContributors": {
|
||||||
"description": "Section for contributors"
|
"description": "Section for contributors"
|
||||||
},
|
},
|
||||||
"aboutMobileDeveloper": "Mobile version developer",
|
"aboutMobileDeveloper": "Mobile-Version Entwickler",
|
||||||
"@aboutMobileDeveloper": {
|
"@aboutMobileDeveloper": {
|
||||||
"description": "Role description for mobile dev"
|
"description": "Role description for mobile dev"
|
||||||
},
|
},
|
||||||
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
|
"aboutOriginalCreator": "Schöpfer des ursprünglichen SpotiFLAC",
|
||||||
"@aboutOriginalCreator": {
|
"@aboutOriginalCreator": {
|
||||||
"description": "Role description for original creator"
|
"description": "Role description for original creator"
|
||||||
},
|
},
|
||||||
|
|||||||
+180
-142
@@ -9,23 +9,23 @@
|
|||||||
"@appDescription": {
|
"@appDescription": {
|
||||||
"description": "App description shown in about page"
|
"description": "App description shown in about page"
|
||||||
},
|
},
|
||||||
"navHome": "Home",
|
"navHome": "ホーム",
|
||||||
"@navHome": {
|
"@navHome": {
|
||||||
"description": "Bottom navigation - Home tab"
|
"description": "Bottom navigation - Home tab"
|
||||||
},
|
},
|
||||||
"navHistory": "History",
|
"navHistory": "履歴",
|
||||||
"@navHistory": {
|
"@navHistory": {
|
||||||
"description": "Bottom navigation - History tab"
|
"description": "Bottom navigation - History tab"
|
||||||
},
|
},
|
||||||
"navSettings": "Settings",
|
"navSettings": "設定",
|
||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Store",
|
"navStore": "ストア",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
"homeTitle": "Home",
|
"homeTitle": "ホーム",
|
||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"@historyTitle": {
|
"@historyTitle": {
|
||||||
"description": "History screen title"
|
"description": "History screen title"
|
||||||
},
|
},
|
||||||
"historyDownloading": "Downloading ({count})",
|
"historyDownloading": "ダウンロード中 ({count})",
|
||||||
"@historyDownloading": {
|
"@historyDownloading": {
|
||||||
"description": "Tab showing active downloads count",
|
"description": "Tab showing active downloads count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -69,19 +69,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"historyDownloaded": "Downloaded",
|
"historyDownloaded": "ダウンロード済み",
|
||||||
"@historyDownloaded": {
|
"@historyDownloaded": {
|
||||||
"description": "Tab showing completed downloads"
|
"description": "Tab showing completed downloads"
|
||||||
},
|
},
|
||||||
"historyFilterAll": "All",
|
"historyFilterAll": "すべて",
|
||||||
"@historyFilterAll": {
|
"@historyFilterAll": {
|
||||||
"description": "Filter chip - show all items"
|
"description": "Filter chip - show all items"
|
||||||
},
|
},
|
||||||
"historyFilterAlbums": "Albums",
|
"historyFilterAlbums": "アルバム",
|
||||||
"@historyFilterAlbums": {
|
"@historyFilterAlbums": {
|
||||||
"description": "Filter chip - show albums only"
|
"description": "Filter chip - show albums only"
|
||||||
},
|
},
|
||||||
"historyFilterSingles": "Singles",
|
"historyFilterSingles": "シングル",
|
||||||
"@historyFilterSingles": {
|
"@historyFilterSingles": {
|
||||||
"description": "Filter chip - show singles only"
|
"description": "Filter chip - show singles only"
|
||||||
},
|
},
|
||||||
@@ -127,31 +127,31 @@
|
|||||||
"@historyNoSinglesSubtitle": {
|
"@historyNoSinglesSubtitle": {
|
||||||
"description": "Empty state subtitle for singles filter"
|
"description": "Empty state subtitle for singles filter"
|
||||||
},
|
},
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "設定",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
},
|
},
|
||||||
"settingsDownload": "Download",
|
"settingsDownload": "ダウンロード",
|
||||||
"@settingsDownload": {
|
"@settingsDownload": {
|
||||||
"description": "Settings section - download options"
|
"description": "Settings section - download options"
|
||||||
},
|
},
|
||||||
"settingsAppearance": "Appearance",
|
"settingsAppearance": "外観",
|
||||||
"@settingsAppearance": {
|
"@settingsAppearance": {
|
||||||
"description": "Settings section - visual customization"
|
"description": "Settings section - visual customization"
|
||||||
},
|
},
|
||||||
"settingsOptions": "Options",
|
"settingsOptions": "オプション",
|
||||||
"@settingsOptions": {
|
"@settingsOptions": {
|
||||||
"description": "Settings section - app options"
|
"description": "Settings section - app options"
|
||||||
},
|
},
|
||||||
"settingsExtensions": "Extensions",
|
"settingsExtensions": "拡張",
|
||||||
"@settingsExtensions": {
|
"@settingsExtensions": {
|
||||||
"description": "Settings section - extension management"
|
"description": "Settings section - extension management"
|
||||||
},
|
},
|
||||||
"settingsAbout": "About",
|
"settingsAbout": "アプリについて",
|
||||||
"@settingsAbout": {
|
"@settingsAbout": {
|
||||||
"description": "Settings section - app info"
|
"description": "Settings section - app info"
|
||||||
},
|
},
|
||||||
"downloadTitle": "Download",
|
"downloadTitle": "ダウンロード",
|
||||||
"@downloadTitle": {
|
"@downloadTitle": {
|
||||||
"description": "Download settings page title"
|
"description": "Download settings page title"
|
||||||
},
|
},
|
||||||
@@ -163,19 +163,19 @@
|
|||||||
"@downloadLocationSubtitle": {
|
"@downloadLocationSubtitle": {
|
||||||
"description": "Subtitle for download location"
|
"description": "Subtitle for download location"
|
||||||
},
|
},
|
||||||
"downloadLocationDefault": "Default location",
|
"downloadLocationDefault": "デフォルトの場所",
|
||||||
"@downloadLocationDefault": {
|
"@downloadLocationDefault": {
|
||||||
"description": "Shown when using default folder"
|
"description": "Shown when using default folder"
|
||||||
},
|
},
|
||||||
"downloadDefaultService": "Default Service",
|
"downloadDefaultService": "デフォルトのサービス",
|
||||||
"@downloadDefaultService": {
|
"@downloadDefaultService": {
|
||||||
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
|
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
|
||||||
},
|
},
|
||||||
"downloadDefaultServiceSubtitle": "Service used for downloads",
|
"downloadDefaultServiceSubtitle": "ダウンロードに使用したサービス",
|
||||||
"@downloadDefaultServiceSubtitle": {
|
"@downloadDefaultServiceSubtitle": {
|
||||||
"description": "Subtitle for default service"
|
"description": "Subtitle for default service"
|
||||||
},
|
},
|
||||||
"downloadDefaultQuality": "Default Quality",
|
"downloadDefaultQuality": "デフォルトの品質",
|
||||||
"@downloadDefaultQuality": {
|
"@downloadDefaultQuality": {
|
||||||
"description": "Setting for audio quality"
|
"description": "Setting for audio quality"
|
||||||
},
|
},
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
"@downloadAskQualitySubtitle": {
|
"@downloadAskQualitySubtitle": {
|
||||||
"description": "Subtitle for ask quality toggle"
|
"description": "Subtitle for ask quality toggle"
|
||||||
},
|
},
|
||||||
"downloadFilenameFormat": "Filename Format",
|
"downloadFilenameFormat": "ファイル名の形式",
|
||||||
"@downloadFilenameFormat": {
|
"@downloadFilenameFormat": {
|
||||||
"description": "Setting for output filename pattern"
|
"description": "Setting for output filename pattern"
|
||||||
},
|
},
|
||||||
@@ -219,27 +219,27 @@
|
|||||||
"@quality128": {
|
"@quality128": {
|
||||||
"description": "Audio quality option - 128kbps MP3"
|
"description": "Audio quality option - 128kbps MP3"
|
||||||
},
|
},
|
||||||
"appearanceTitle": "Appearance",
|
"appearanceTitle": "外観",
|
||||||
"@appearanceTitle": {
|
"@appearanceTitle": {
|
||||||
"description": "Appearance settings page title"
|
"description": "Appearance settings page title"
|
||||||
},
|
},
|
||||||
"appearanceTheme": "Theme",
|
"appearanceTheme": "テーマ",
|
||||||
"@appearanceTheme": {
|
"@appearanceTheme": {
|
||||||
"description": "Theme mode setting"
|
"description": "Theme mode setting"
|
||||||
},
|
},
|
||||||
"appearanceThemeSystem": "System",
|
"appearanceThemeSystem": "システム",
|
||||||
"@appearanceThemeSystem": {
|
"@appearanceThemeSystem": {
|
||||||
"description": "Follow system theme"
|
"description": "Follow system theme"
|
||||||
},
|
},
|
||||||
"appearanceThemeLight": "Light",
|
"appearanceThemeLight": "ライト",
|
||||||
"@appearanceThemeLight": {
|
"@appearanceThemeLight": {
|
||||||
"description": "Light theme"
|
"description": "Light theme"
|
||||||
},
|
},
|
||||||
"appearanceThemeDark": "Dark",
|
"appearanceThemeDark": "ダーク",
|
||||||
"@appearanceThemeDark": {
|
"@appearanceThemeDark": {
|
||||||
"description": "Dark theme"
|
"description": "Dark theme"
|
||||||
},
|
},
|
||||||
"appearanceDynamicColor": "Dynamic Color",
|
"appearanceDynamicColor": "ダイナミックカラー",
|
||||||
"@appearanceDynamicColor": {
|
"@appearanceDynamicColor": {
|
||||||
"description": "Material You dynamic colors"
|
"description": "Material You dynamic colors"
|
||||||
},
|
},
|
||||||
@@ -247,31 +247,31 @@
|
|||||||
"@appearanceDynamicColorSubtitle": {
|
"@appearanceDynamicColorSubtitle": {
|
||||||
"description": "Subtitle for dynamic color"
|
"description": "Subtitle for dynamic color"
|
||||||
},
|
},
|
||||||
"appearanceAccentColor": "Accent Color",
|
"appearanceAccentColor": "アクセントカラー",
|
||||||
"@appearanceAccentColor": {
|
"@appearanceAccentColor": {
|
||||||
"description": "Custom accent color picker"
|
"description": "Custom accent color picker"
|
||||||
},
|
},
|
||||||
"appearanceHistoryView": "History View",
|
"appearanceHistoryView": "履歴の表示",
|
||||||
"@appearanceHistoryView": {
|
"@appearanceHistoryView": {
|
||||||
"description": "Layout style for history"
|
"description": "Layout style for history"
|
||||||
},
|
},
|
||||||
"appearanceHistoryViewList": "List",
|
"appearanceHistoryViewList": "リスト",
|
||||||
"@appearanceHistoryViewList": {
|
"@appearanceHistoryViewList": {
|
||||||
"description": "List layout option"
|
"description": "List layout option"
|
||||||
},
|
},
|
||||||
"appearanceHistoryViewGrid": "Grid",
|
"appearanceHistoryViewGrid": "グリッド",
|
||||||
"@appearanceHistoryViewGrid": {
|
"@appearanceHistoryViewGrid": {
|
||||||
"description": "Grid layout option"
|
"description": "Grid layout option"
|
||||||
},
|
},
|
||||||
"optionsTitle": "Options",
|
"optionsTitle": "オプション",
|
||||||
"@optionsTitle": {
|
"@optionsTitle": {
|
||||||
"description": "Options settings page title"
|
"description": "Options settings page title"
|
||||||
},
|
},
|
||||||
"optionsSearchSource": "Search Source",
|
"optionsSearchSource": "検索ソース",
|
||||||
"@optionsSearchSource": {
|
"@optionsSearchSource": {
|
||||||
"description": "Section for search provider settings"
|
"description": "Section for search provider settings"
|
||||||
},
|
},
|
||||||
"optionsPrimaryProvider": "Primary Provider",
|
"optionsPrimaryProvider": "プライマリーのプロバイダー",
|
||||||
"@optionsPrimaryProvider": {
|
"@optionsPrimaryProvider": {
|
||||||
"description": "Main search provider setting"
|
"description": "Main search provider setting"
|
||||||
},
|
},
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
"@optionsPrimaryProviderSubtitle": {
|
"@optionsPrimaryProviderSubtitle": {
|
||||||
"description": "Subtitle for primary provider"
|
"description": "Subtitle for primary provider"
|
||||||
},
|
},
|
||||||
"optionsUsingExtension": "Using extension: {extensionName}",
|
"optionsUsingExtension": "拡張の使用: {extensionName}",
|
||||||
"@optionsUsingExtension": {
|
"@optionsUsingExtension": {
|
||||||
"description": "Shows active extension name",
|
"description": "Shows active extension name",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -300,7 +300,7 @@
|
|||||||
"@optionsAutoFallbackSubtitle": {
|
"@optionsAutoFallbackSubtitle": {
|
||||||
"description": "Subtitle for auto fallback"
|
"description": "Subtitle for auto fallback"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
"optionsUseExtensionProviders": "拡張のプロバイダーを使用する",
|
||||||
"@optionsUseExtensionProviders": {
|
"@optionsUseExtensionProviders": {
|
||||||
"description": "Enable extension download providers"
|
"description": "Enable extension download providers"
|
||||||
},
|
},
|
||||||
@@ -308,11 +308,11 @@
|
|||||||
"@optionsUseExtensionProvidersOn": {
|
"@optionsUseExtensionProvidersOn": {
|
||||||
"description": "Status when extension providers enabled"
|
"description": "Status when extension providers enabled"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
"optionsUseExtensionProvidersOff": "内蔵のプロバイダーのみを使用する",
|
||||||
"@optionsUseExtensionProvidersOff": {
|
"@optionsUseExtensionProvidersOff": {
|
||||||
"description": "Status when extension providers disabled"
|
"description": "Status when extension providers disabled"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyrics": "Embed Lyrics",
|
"optionsEmbedLyrics": "歌詞を埋め込む",
|
||||||
"@optionsEmbedLyrics": {
|
"@optionsEmbedLyrics": {
|
||||||
"description": "Embed lyrics in audio files"
|
"description": "Embed lyrics in audio files"
|
||||||
},
|
},
|
||||||
@@ -320,7 +320,7 @@
|
|||||||
"@optionsEmbedLyricsSubtitle": {
|
"@optionsEmbedLyricsSubtitle": {
|
||||||
"description": "Subtitle for embed lyrics"
|
"description": "Subtitle for embed lyrics"
|
||||||
},
|
},
|
||||||
"optionsMaxQualityCover": "Max Quality Cover",
|
"optionsMaxQualityCover": "最大品質のカバー",
|
||||||
"@optionsMaxQualityCover": {
|
"@optionsMaxQualityCover": {
|
||||||
"description": "Download highest quality album art"
|
"description": "Download highest quality album art"
|
||||||
},
|
},
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Extension Store",
|
"optionsExtensionStore": "拡張ストア",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
"optionsCheckUpdates": "Check for Updates",
|
"optionsCheckUpdates": "更新を確認",
|
||||||
"@optionsCheckUpdates": {
|
"@optionsCheckUpdates": {
|
||||||
"description": "Auto update check toggle"
|
"description": "Auto update check toggle"
|
||||||
},
|
},
|
||||||
@@ -365,15 +365,15 @@
|
|||||||
"@optionsCheckUpdatesSubtitle": {
|
"@optionsCheckUpdatesSubtitle": {
|
||||||
"description": "Subtitle for update check"
|
"description": "Subtitle for update check"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannel": "Update Channel",
|
"optionsUpdateChannel": "更新チャンネル",
|
||||||
"@optionsUpdateChannel": {
|
"@optionsUpdateChannel": {
|
||||||
"description": "Stable vs preview releases"
|
"description": "Stable vs preview releases"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelStable": "Stable releases only",
|
"optionsUpdateChannelStable": "安定版リリースのみ",
|
||||||
"@optionsUpdateChannelStable": {
|
"@optionsUpdateChannelStable": {
|
||||||
"description": "Only stable updates"
|
"description": "Only stable updates"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelPreview": "Get preview releases",
|
"optionsUpdateChannelPreview": "プレビューリリースを入手",
|
||||||
"@optionsUpdateChannelPreview": {
|
"@optionsUpdateChannelPreview": {
|
||||||
"description": "Include beta/preview updates"
|
"description": "Include beta/preview updates"
|
||||||
},
|
},
|
||||||
@@ -401,11 +401,11 @@
|
|||||||
"@optionsDetailedLoggingOff": {
|
"@optionsDetailedLoggingOff": {
|
||||||
"description": "Status when logging disabled"
|
"description": "Status when logging disabled"
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentials": "Spotify Credentials",
|
"optionsSpotifyCredentials": "Spotify の認証情報",
|
||||||
"@optionsSpotifyCredentials": {
|
"@optionsSpotifyCredentials": {
|
||||||
"description": "Spotify API credentials setting"
|
"description": "Spotify API credentials setting"
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
|
"optionsSpotifyCredentialsConfigured": "クライアント ID: {clientId}...",
|
||||||
"@optionsSpotifyCredentialsConfigured": {
|
"@optionsSpotifyCredentialsConfigured": {
|
||||||
"description": "Shows configured client ID preview",
|
"description": "Shows configured client ID preview",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -422,23 +422,23 @@
|
|||||||
"@optionsSpotifyWarning": {
|
"@optionsSpotifyWarning": {
|
||||||
"description": "Info about Spotify API requirement"
|
"description": "Info about Spotify API requirement"
|
||||||
},
|
},
|
||||||
"extensionsTitle": "Extensions",
|
"extensionsTitle": "拡張",
|
||||||
"@extensionsTitle": {
|
"@extensionsTitle": {
|
||||||
"description": "Extensions page title"
|
"description": "Extensions page title"
|
||||||
},
|
},
|
||||||
"extensionsInstalled": "Installed Extensions",
|
"extensionsInstalled": "インストール済みの拡張",
|
||||||
"@extensionsInstalled": {
|
"@extensionsInstalled": {
|
||||||
"description": "Section header for installed extensions"
|
"description": "Section header for installed extensions"
|
||||||
},
|
},
|
||||||
"extensionsNone": "No extensions installed",
|
"extensionsNone": "拡張はインストールされていません",
|
||||||
"@extensionsNone": {
|
"@extensionsNone": {
|
||||||
"description": "Empty state title"
|
"description": "Empty state title"
|
||||||
},
|
},
|
||||||
"extensionsNoneSubtitle": "Install extensions from the Store tab",
|
"extensionsNoneSubtitle": "ストアタブから拡張をインストール",
|
||||||
"@extensionsNoneSubtitle": {
|
"@extensionsNoneSubtitle": {
|
||||||
"description": "Empty state subtitle"
|
"description": "Empty state subtitle"
|
||||||
},
|
},
|
||||||
"extensionsEnabled": "Enabled",
|
"extensionsEnabled": "有効",
|
||||||
"@extensionsEnabled": {
|
"@extensionsEnabled": {
|
||||||
"description": "Extension status - active"
|
"description": "Extension status - active"
|
||||||
},
|
},
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
"@extensionsDisabled": {
|
"@extensionsDisabled": {
|
||||||
"description": "Extension status - inactive"
|
"description": "Extension status - inactive"
|
||||||
},
|
},
|
||||||
"extensionsVersion": "Version {version}",
|
"extensionsVersion": "バージョン {version}",
|
||||||
"@extensionsVersion": {
|
"@extensionsVersion": {
|
||||||
"description": "Extension version display",
|
"description": "Extension version display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -455,7 +455,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensionsAuthor": "by {author}",
|
"extensionsAuthor": "作者 {author}",
|
||||||
"@extensionsAuthor": {
|
"@extensionsAuthor": {
|
||||||
"description": "Extension author credit",
|
"description": "Extension author credit",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -464,43 +464,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensionsUninstall": "Uninstall",
|
"extensionsUninstall": "アンインストール",
|
||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"extensionsSetAsSearch": "Set as Search Provider",
|
"extensionsSetAsSearch": "検索プロバイダーを設定",
|
||||||
"@extensionsSetAsSearch": {
|
"@extensionsSetAsSearch": {
|
||||||
"description": "Use extension for search"
|
"description": "Use extension for search"
|
||||||
},
|
},
|
||||||
"storeTitle": "Extension Store",
|
"storeTitle": "拡張ストア",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
"storeSearch": "Search extensions...",
|
"storeSearch": "拡張を検索...",
|
||||||
"@storeSearch": {
|
"@storeSearch": {
|
||||||
"description": "Store search placeholder"
|
"description": "Store search placeholder"
|
||||||
},
|
},
|
||||||
"storeInstall": "Install",
|
"storeInstall": "インストール",
|
||||||
"@storeInstall": {
|
"@storeInstall": {
|
||||||
"description": "Install extension button"
|
"description": "Install extension button"
|
||||||
},
|
},
|
||||||
"storeInstalled": "Installed",
|
"storeInstalled": "インストール済み",
|
||||||
"@storeInstalled": {
|
"@storeInstalled": {
|
||||||
"description": "Already installed badge"
|
"description": "Already installed badge"
|
||||||
},
|
},
|
||||||
"storeUpdate": "Update",
|
"storeUpdate": "更新",
|
||||||
"@storeUpdate": {
|
"@storeUpdate": {
|
||||||
"description": "Update available button"
|
"description": "Update available button"
|
||||||
},
|
},
|
||||||
"aboutTitle": "About",
|
"aboutTitle": "アプリについて",
|
||||||
"@aboutTitle": {
|
"@aboutTitle": {
|
||||||
"description": "About page title"
|
"description": "About page title"
|
||||||
},
|
},
|
||||||
"aboutContributors": "Contributors",
|
"aboutContributors": "貢献者",
|
||||||
"@aboutContributors": {
|
"@aboutContributors": {
|
||||||
"description": "Section for contributors"
|
"description": "Section for contributors"
|
||||||
},
|
},
|
||||||
"aboutMobileDeveloper": "Mobile version developer",
|
"aboutMobileDeveloper": "モバイルバージョンの開発者",
|
||||||
"@aboutMobileDeveloper": {
|
"@aboutMobileDeveloper": {
|
||||||
"description": "Role description for mobile dev"
|
"description": "Role description for mobile dev"
|
||||||
},
|
},
|
||||||
@@ -512,23 +512,23 @@
|
|||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
"aboutSpecialThanks": "Special Thanks",
|
"aboutSpecialThanks": "スペシャルサンクス",
|
||||||
"@aboutSpecialThanks": {
|
"@aboutSpecialThanks": {
|
||||||
"description": "Section for special thanks"
|
"description": "Section for special thanks"
|
||||||
},
|
},
|
||||||
"aboutLinks": "Links",
|
"aboutLinks": "リンク",
|
||||||
"@aboutLinks": {
|
"@aboutLinks": {
|
||||||
"description": "Section for external links"
|
"description": "Section for external links"
|
||||||
},
|
},
|
||||||
"aboutMobileSource": "Mobile source code",
|
"aboutMobileSource": "モバイル版のソースコード",
|
||||||
"@aboutMobileSource": {
|
"@aboutMobileSource": {
|
||||||
"description": "Link to mobile GitHub repo"
|
"description": "Link to mobile GitHub repo"
|
||||||
},
|
},
|
||||||
"aboutPCSource": "PC source code",
|
"aboutPCSource": "PC 版のソースコード",
|
||||||
"@aboutPCSource": {
|
"@aboutPCSource": {
|
||||||
"description": "Link to PC GitHub repo"
|
"description": "Link to PC GitHub repo"
|
||||||
},
|
},
|
||||||
"aboutReportIssue": "Report an issue",
|
"aboutReportIssue": "Issue で報告する",
|
||||||
"@aboutReportIssue": {
|
"@aboutReportIssue": {
|
||||||
"description": "Link to report bugs"
|
"description": "Link to report bugs"
|
||||||
},
|
},
|
||||||
@@ -536,7 +536,7 @@
|
|||||||
"@aboutReportIssueSubtitle": {
|
"@aboutReportIssueSubtitle": {
|
||||||
"description": "Subtitle for report issue"
|
"description": "Subtitle for report issue"
|
||||||
},
|
},
|
||||||
"aboutFeatureRequest": "Feature request",
|
"aboutFeatureRequest": "機能の要望",
|
||||||
"@aboutFeatureRequest": {
|
"@aboutFeatureRequest": {
|
||||||
"description": "Link to suggest features"
|
"description": "Link to suggest features"
|
||||||
},
|
},
|
||||||
@@ -548,19 +548,19 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
"aboutBuyMeCoffee": "コーヒーを買ってください",
|
||||||
"@aboutBuyMeCoffee": {
|
"@aboutBuyMeCoffee": {
|
||||||
"description": "Donation link"
|
"description": "Donation link"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
"aboutBuyMeCoffeeSubtitle": "Ko-fi で開発をサポートします",
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
"@aboutBuyMeCoffeeSubtitle": {
|
||||||
"description": "Subtitle for donation"
|
"description": "Subtitle for donation"
|
||||||
},
|
},
|
||||||
"aboutApp": "App",
|
"aboutApp": "アプリ",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
},
|
},
|
||||||
"aboutVersion": "Version",
|
"aboutVersion": "バージョン",
|
||||||
"@aboutVersion": {
|
"@aboutVersion": {
|
||||||
"description": "Version info label"
|
"description": "Version info label"
|
||||||
},
|
},
|
||||||
@@ -625,11 +625,11 @@
|
|||||||
"@artistAlbums": {
|
"@artistAlbums": {
|
||||||
"description": "Section header for artist albums"
|
"description": "Section header for artist albums"
|
||||||
},
|
},
|
||||||
"artistSingles": "Singles & EPs",
|
"artistSingles": "シングルと EP",
|
||||||
"@artistSingles": {
|
"@artistSingles": {
|
||||||
"description": "Section header for singles/EPs"
|
"description": "Section header for singles/EPs"
|
||||||
},
|
},
|
||||||
"artistCompilations": "Compilations",
|
"artistCompilations": "コンピレーション",
|
||||||
"@artistCompilations": {
|
"@artistCompilations": {
|
||||||
"description": "Section header for compilations"
|
"description": "Section header for compilations"
|
||||||
},
|
},
|
||||||
@@ -642,6 +642,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artistPopular": "Popular",
|
||||||
|
"@artistPopular": {
|
||||||
|
"description": "Section header for popular/top tracks"
|
||||||
|
},
|
||||||
|
"artistMonthlyListeners": "{count} monthly listeners",
|
||||||
|
"@artistMonthlyListeners": {
|
||||||
|
"description": "Monthly listener count display",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Formatted listener count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Track Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
@@ -730,15 +744,15 @@
|
|||||||
"@setupChooseFolder": {
|
"@setupChooseFolder": {
|
||||||
"description": "Button to pick folder"
|
"description": "Button to pick folder"
|
||||||
},
|
},
|
||||||
"setupContinue": "Continue",
|
"setupContinue": "続行",
|
||||||
"@setupContinue": {
|
"@setupContinue": {
|
||||||
"description": "Continue to next step button"
|
"description": "Continue to next step button"
|
||||||
},
|
},
|
||||||
"setupSkip": "Skip for now",
|
"setupSkip": "今はスキップ",
|
||||||
"@setupSkip": {
|
"@setupSkip": {
|
||||||
"description": "Skip current step button"
|
"description": "Skip current step button"
|
||||||
},
|
},
|
||||||
"setupStorageAccessRequired": "Storage Access Required",
|
"setupStorageAccessRequired": "ストレージアクセスが必要です",
|
||||||
"@setupStorageAccessRequired": {
|
"@setupStorageAccessRequired": {
|
||||||
"description": "Title when storage access needed"
|
"description": "Title when storage access needed"
|
||||||
},
|
},
|
||||||
@@ -841,7 +855,7 @@
|
|||||||
"@setupStepSpotify": {
|
"@setupStepSpotify": {
|
||||||
"description": "Setup step indicator - Spotify API"
|
"description": "Setup step indicator - Spotify API"
|
||||||
},
|
},
|
||||||
"setupStepPermission": "Permission",
|
"setupStepPermission": "権限",
|
||||||
"@setupStepPermission": {
|
"@setupStepPermission": {
|
||||||
"description": "Setup step indicator - permission"
|
"description": "Setup step indicator - permission"
|
||||||
},
|
},
|
||||||
@@ -861,7 +875,7 @@
|
|||||||
"@setupNotificationGranted": {
|
"@setupNotificationGranted": {
|
||||||
"description": "Success message for notification permission"
|
"description": "Success message for notification permission"
|
||||||
},
|
},
|
||||||
"setupNotificationEnable": "Enable Notifications",
|
"setupNotificationEnable": "通知を有効化する",
|
||||||
"@setupNotificationEnable": {
|
"@setupNotificationEnable": {
|
||||||
"description": "Button to enable notifications"
|
"description": "Button to enable notifications"
|
||||||
},
|
},
|
||||||
@@ -869,7 +883,7 @@
|
|||||||
"@setupNotificationDescription": {
|
"@setupNotificationDescription": {
|
||||||
"description": "Explanation for notifications"
|
"description": "Explanation for notifications"
|
||||||
},
|
},
|
||||||
"setupFolderSelected": "Download Folder Selected!",
|
"setupFolderSelected": "ダウンロードフォルダが選択済みです!",
|
||||||
"@setupFolderSelected": {
|
"@setupFolderSelected": {
|
||||||
"description": "Success message for folder selection"
|
"description": "Success message for folder selection"
|
||||||
},
|
},
|
||||||
@@ -889,7 +903,7 @@
|
|||||||
"@setupSelectFolder": {
|
"@setupSelectFolder": {
|
||||||
"description": "Button to select folder"
|
"description": "Button to select folder"
|
||||||
},
|
},
|
||||||
"setupSpotifyApiOptional": "Spotify API (Optional)",
|
"setupSpotifyApiOptional": "Spotify API (任意)",
|
||||||
"@setupSpotifyApiOptional": {
|
"@setupSpotifyApiOptional": {
|
||||||
"description": "Spotify API step title"
|
"description": "Spotify API step title"
|
||||||
},
|
},
|
||||||
@@ -897,7 +911,7 @@
|
|||||||
"@setupSpotifyApiDescription": {
|
"@setupSpotifyApiDescription": {
|
||||||
"description": "Explanation for Spotify API"
|
"description": "Explanation for Spotify API"
|
||||||
},
|
},
|
||||||
"setupUseSpotifyApi": "Use Spotify API",
|
"setupUseSpotifyApi": "Spotify API を使用する",
|
||||||
"@setupUseSpotifyApi": {
|
"@setupUseSpotifyApi": {
|
||||||
"description": "Toggle to enable Spotify API"
|
"description": "Toggle to enable Spotify API"
|
||||||
},
|
},
|
||||||
@@ -905,15 +919,15 @@
|
|||||||
"@setupEnterCredentialsBelow": {
|
"@setupEnterCredentialsBelow": {
|
||||||
"description": "Prompt to enter credentials"
|
"description": "Prompt to enter credentials"
|
||||||
},
|
},
|
||||||
"setupUsingDeezer": "Using Deezer (no account needed)",
|
"setupUsingDeezer": "Deezer を使用中 (アカウントは不要です)",
|
||||||
"@setupUsingDeezer": {
|
"@setupUsingDeezer": {
|
||||||
"description": "Status when using Deezer"
|
"description": "Status when using Deezer"
|
||||||
},
|
},
|
||||||
"setupEnterClientId": "Enter Spotify Client ID",
|
"setupEnterClientId": "Spotify クライアント ID を入力",
|
||||||
"@setupEnterClientId": {
|
"@setupEnterClientId": {
|
||||||
"description": "Placeholder for client ID field"
|
"description": "Placeholder for client ID field"
|
||||||
},
|
},
|
||||||
"setupEnterClientSecret": "Enter Spotify Client Secret",
|
"setupEnterClientSecret": "Spotify クライアントシークレットを入力",
|
||||||
"@setupEnterClientSecret": {
|
"@setupEnterClientSecret": {
|
||||||
"description": "Placeholder for client secret field"
|
"description": "Placeholder for client secret field"
|
||||||
},
|
},
|
||||||
@@ -937,15 +951,15 @@
|
|||||||
"@setupNotificationBackgroundDescription": {
|
"@setupNotificationBackgroundDescription": {
|
||||||
"description": "Detailed notification explanation"
|
"description": "Detailed notification explanation"
|
||||||
},
|
},
|
||||||
"setupSkipForNow": "Skip for now",
|
"setupSkipForNow": "今はスキップ",
|
||||||
"@setupSkipForNow": {
|
"@setupSkipForNow": {
|
||||||
"description": "Skip button text"
|
"description": "Skip button text"
|
||||||
},
|
},
|
||||||
"setupBack": "Back",
|
"setupBack": "戻る",
|
||||||
"@setupBack": {
|
"@setupBack": {
|
||||||
"description": "Back button text"
|
"description": "Back button text"
|
||||||
},
|
},
|
||||||
"setupNext": "Next",
|
"setupNext": "次へ",
|
||||||
"@setupNext": {
|
"@setupNext": {
|
||||||
"description": "Next button text"
|
"description": "Next button text"
|
||||||
},
|
},
|
||||||
@@ -953,7 +967,7 @@
|
|||||||
"@setupGetStarted": {
|
"@setupGetStarted": {
|
||||||
"description": "Final setup button"
|
"description": "Final setup button"
|
||||||
},
|
},
|
||||||
"setupSkipAndStart": "Skip & Start",
|
"setupSkipAndStart": "スキップと開始",
|
||||||
"@setupSkipAndStart": {
|
"@setupSkipAndStart": {
|
||||||
"description": "Skip setup and start app"
|
"description": "Skip setup and start app"
|
||||||
},
|
},
|
||||||
@@ -1069,7 +1083,7 @@
|
|||||||
"@dialogRemoveExtensionMessage": {
|
"@dialogRemoveExtensionMessage": {
|
||||||
"description": "Dialog message - uninstall confirmation"
|
"description": "Dialog message - uninstall confirmation"
|
||||||
},
|
},
|
||||||
"dialogUninstallExtension": "Uninstall Extension?",
|
"dialogUninstallExtension": "拡張をアンインストールしますか?",
|
||||||
"@dialogUninstallExtension": {
|
"@dialogUninstallExtension": {
|
||||||
"description": "Dialog title - uninstall extension"
|
"description": "Dialog title - uninstall extension"
|
||||||
},
|
},
|
||||||
@@ -1103,7 +1117,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistTitle": "Import Playlist",
|
"dialogImportPlaylistTitle": "プレイリストをインポート",
|
||||||
"@dialogImportPlaylistTitle": {
|
"@dialogImportPlaylistTitle": {
|
||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
@@ -1242,7 +1256,7 @@
|
|||||||
"@snackbarFailedToUpdate": {
|
"@snackbarFailedToUpdate": {
|
||||||
"description": "Snackbar - extension update error"
|
"description": "Snackbar - extension update error"
|
||||||
},
|
},
|
||||||
"errorRateLimited": "Rate Limited",
|
"errorRateLimited": "レート制限",
|
||||||
"@errorRateLimited": {
|
"@errorRateLimited": {
|
||||||
"description": "Error title - too many requests"
|
"description": "Error title - too many requests"
|
||||||
},
|
},
|
||||||
@@ -1509,7 +1523,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"updateDownload": "Download",
|
"updateDownload": "ダウンロード",
|
||||||
"@updateDownload": {
|
"@updateDownload": {
|
||||||
"description": "Update button - download update"
|
"description": "Update button - download update"
|
||||||
},
|
},
|
||||||
@@ -1537,7 +1551,7 @@
|
|||||||
"@updateNewVersionReady": {
|
"@updateNewVersionReady": {
|
||||||
"description": "Update subtitle"
|
"description": "Update subtitle"
|
||||||
},
|
},
|
||||||
"updateCurrent": "Current",
|
"updateCurrent": "現在",
|
||||||
"@updateCurrent": {
|
"@updateCurrent": {
|
||||||
"description": "Label for current version"
|
"description": "Label for current version"
|
||||||
},
|
},
|
||||||
@@ -1669,15 +1683,15 @@
|
|||||||
"@logClearLogsMessage": {
|
"@logClearLogsMessage": {
|
||||||
"description": "Clear logs confirmation message"
|
"description": "Clear logs confirmation message"
|
||||||
},
|
},
|
||||||
"logIspBlocking": "ISP BLOCKING DETECTED",
|
"logIspBlocking": "ISP のブロックを検出しました",
|
||||||
"@logIspBlocking": {
|
"@logIspBlocking": {
|
||||||
"description": "Error category - ISP blocking"
|
"description": "Error category - ISP blocking"
|
||||||
},
|
},
|
||||||
"logRateLimited": "RATE LIMITED",
|
"logRateLimited": "レート制限",
|
||||||
"@logRateLimited": {
|
"@logRateLimited": {
|
||||||
"description": "Error category - rate limiting"
|
"description": "Error category - rate limiting"
|
||||||
},
|
},
|
||||||
"logNetworkError": "NETWORK ERROR",
|
"logNetworkError": "ネットワークエラー",
|
||||||
"@logNetworkError": {
|
"@logNetworkError": {
|
||||||
"description": "Error category - network issues"
|
"description": "Error category - network issues"
|
||||||
},
|
},
|
||||||
@@ -1851,27 +1865,15 @@
|
|||||||
},
|
},
|
||||||
"sectionLanguage": "Language",
|
"sectionLanguage": "Language",
|
||||||
"@sectionLanguage": {
|
"@sectionLanguage": {
|
||||||
"description": "Settings section header for language selection"
|
"description": "Settings section header for language"
|
||||||
},
|
},
|
||||||
"appearanceLanguage": "App Language",
|
"appearanceLanguage": "App Language",
|
||||||
"@appearanceLanguage": {
|
"@appearanceLanguage": {
|
||||||
"description": "Setting title for language selection"
|
"description": "Language setting title"
|
||||||
},
|
},
|
||||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||||
"@appearanceLanguageSubtitle": {
|
"@appearanceLanguageSubtitle": {
|
||||||
"description": "Subtitle for language setting"
|
"description": "Language setting subtitle"
|
||||||
},
|
|
||||||
"languageSystem": "System Default",
|
|
||||||
"@languageSystem": {
|
|
||||||
"description": "Use device system language"
|
|
||||||
},
|
|
||||||
"languageEnglish": "English",
|
|
||||||
"@languageEnglish": {
|
|
||||||
"description": "English language option"
|
|
||||||
},
|
|
||||||
"languageIndonesian": "Bahasa Indonesia",
|
|
||||||
"@languageIndonesian": {
|
|
||||||
"description": "Indonesian language option"
|
|
||||||
},
|
},
|
||||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||||
"@settingsAppearanceSubtitle": {
|
"@settingsAppearanceSubtitle": {
|
||||||
@@ -1939,27 +1941,27 @@
|
|||||||
"@trackMetadata": {
|
"@trackMetadata": {
|
||||||
"description": "Tab title - track metadata"
|
"description": "Tab title - track metadata"
|
||||||
},
|
},
|
||||||
"trackFileInfo": "File Info",
|
"trackFileInfo": "ファイル情報",
|
||||||
"@trackFileInfo": {
|
"@trackFileInfo": {
|
||||||
"description": "Tab title - file information"
|
"description": "Tab title - file information"
|
||||||
},
|
},
|
||||||
"trackLyrics": "Lyrics",
|
"trackLyrics": "歌詞",
|
||||||
"@trackLyrics": {
|
"@trackLyrics": {
|
||||||
"description": "Tab title - lyrics"
|
"description": "Tab title - lyrics"
|
||||||
},
|
},
|
||||||
"trackFileNotFound": "File not found",
|
"trackFileNotFound": "ファイルがありません",
|
||||||
"@trackFileNotFound": {
|
"@trackFileNotFound": {
|
||||||
"description": "Error - file doesn't exist"
|
"description": "Error - file doesn't exist"
|
||||||
},
|
},
|
||||||
"trackOpenInDeezer": "Open in Deezer",
|
"trackOpenInDeezer": "Deezer で開く",
|
||||||
"@trackOpenInDeezer": {
|
"@trackOpenInDeezer": {
|
||||||
"description": "Action - open track in Deezer app"
|
"description": "Action - open track in Deezer app"
|
||||||
},
|
},
|
||||||
"trackOpenInSpotify": "Open in Spotify",
|
"trackOpenInSpotify": "Spotify で開く",
|
||||||
"@trackOpenInSpotify": {
|
"@trackOpenInSpotify": {
|
||||||
"description": "Action - open track in Spotify app"
|
"description": "Action - open track in Spotify app"
|
||||||
},
|
},
|
||||||
"trackTrackName": "Track name",
|
"trackTrackName": "トラック名",
|
||||||
"@trackTrackName": {
|
"@trackTrackName": {
|
||||||
"description": "Metadata label - track title"
|
"description": "Metadata label - track title"
|
||||||
},
|
},
|
||||||
@@ -2131,11 +2133,11 @@
|
|||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
"extensionDefaultProviderSubtitle": "内蔵の検索を使用する",
|
||||||
"@extensionDefaultProviderSubtitle": {
|
"@extensionDefaultProviderSubtitle": {
|
||||||
"description": "Subtitle for default provider"
|
"description": "Subtitle for default provider"
|
||||||
},
|
},
|
||||||
"extensionAuthor": "Author",
|
"extensionAuthor": "作者",
|
||||||
"@extensionAuthor": {
|
"@extensionAuthor": {
|
||||||
"description": "Extension detail - author"
|
"description": "Extension detail - author"
|
||||||
},
|
},
|
||||||
@@ -2143,7 +2145,7 @@
|
|||||||
"@extensionId": {
|
"@extensionId": {
|
||||||
"description": "Extension detail - unique ID"
|
"description": "Extension detail - unique ID"
|
||||||
},
|
},
|
||||||
"extensionError": "Error",
|
"extensionError": "エラー",
|
||||||
"@extensionError": {
|
"@extensionError": {
|
||||||
"description": "Extension detail - error message"
|
"description": "Extension detail - error message"
|
||||||
},
|
},
|
||||||
@@ -2183,19 +2185,19 @@
|
|||||||
"@extensionSettings": {
|
"@extensionSettings": {
|
||||||
"description": "Section header - extension settings"
|
"description": "Section header - extension settings"
|
||||||
},
|
},
|
||||||
"extensionRemoveButton": "Remove Extension",
|
"extensionRemoveButton": "拡張を削除",
|
||||||
"@extensionRemoveButton": {
|
"@extensionRemoveButton": {
|
||||||
"description": "Button to uninstall extension"
|
"description": "Button to uninstall extension"
|
||||||
},
|
},
|
||||||
"extensionUpdated": "Updated",
|
"extensionUpdated": "更新済み",
|
||||||
"@extensionUpdated": {
|
"@extensionUpdated": {
|
||||||
"description": "Extension detail - last update"
|
"description": "Extension detail - last update"
|
||||||
},
|
},
|
||||||
"extensionMinAppVersion": "Min App Version",
|
"extensionMinAppVersion": "最小のアプリバージョン",
|
||||||
"@extensionMinAppVersion": {
|
"@extensionMinAppVersion": {
|
||||||
"description": "Extension detail - minimum app version"
|
"description": "Extension detail - minimum app version"
|
||||||
},
|
},
|
||||||
"extensionCustomTrackMatching": "Custom Track Matching",
|
"extensionCustomTrackMatching": "カスタムトラックマッチング",
|
||||||
"@extensionCustomTrackMatching": {
|
"@extensionCustomTrackMatching": {
|
||||||
"description": "Capability - custom track matching algorithm"
|
"description": "Capability - custom track matching algorithm"
|
||||||
},
|
},
|
||||||
@@ -2234,11 +2236,11 @@
|
|||||||
"@extensionsProviderPrioritySection": {
|
"@extensionsProviderPrioritySection": {
|
||||||
"description": "Section header - provider priority"
|
"description": "Section header - provider priority"
|
||||||
},
|
},
|
||||||
"extensionsInstalledSection": "Installed Extensions",
|
"extensionsInstalledSection": "インストール済みの拡張",
|
||||||
"@extensionsInstalledSection": {
|
"@extensionsInstalledSection": {
|
||||||
"description": "Section header - installed extensions"
|
"description": "Section header - installed extensions"
|
||||||
},
|
},
|
||||||
"extensionsNoExtensions": "No extensions installed",
|
"extensionsNoExtensions": "拡張はインストールされていません",
|
||||||
"@extensionsNoExtensions": {
|
"@extensionsNoExtensions": {
|
||||||
"description": "Empty state - no extensions"
|
"description": "Empty state - no extensions"
|
||||||
},
|
},
|
||||||
@@ -2246,7 +2248,7 @@
|
|||||||
"@extensionsNoExtensionsSubtitle": {
|
"@extensionsNoExtensionsSubtitle": {
|
||||||
"description": "Empty state subtitle"
|
"description": "Empty state subtitle"
|
||||||
},
|
},
|
||||||
"extensionsInstallButton": "Install Extension",
|
"extensionsInstallButton": "拡張をインストール",
|
||||||
"@extensionsInstallButton": {
|
"@extensionsInstallButton": {
|
||||||
"description": "Button to install extension from file"
|
"description": "Button to install extension from file"
|
||||||
},
|
},
|
||||||
@@ -2302,7 +2304,7 @@
|
|||||||
"@extensionsErrorLoading": {
|
"@extensionsErrorLoading": {
|
||||||
"description": "Error message when extension fails to load"
|
"description": "Error message when extension fails to load"
|
||||||
},
|
},
|
||||||
"qualityFlacLossless": "FLAC Lossless",
|
"qualityFlacLossless": "FLAC ロスレス",
|
||||||
"@qualityFlacLossless": {
|
"@qualityFlacLossless": {
|
||||||
"description": "Quality option - CD quality FLAC"
|
"description": "Quality option - CD quality FLAC"
|
||||||
},
|
},
|
||||||
@@ -2310,19 +2312,19 @@
|
|||||||
"@qualityFlacLosslessSubtitle": {
|
"@qualityFlacLosslessSubtitle": {
|
||||||
"description": "Technical spec for lossless"
|
"description": "Technical spec for lossless"
|
||||||
},
|
},
|
||||||
"qualityHiResFlac": "Hi-Res FLAC",
|
"qualityHiResFlac": "ハイレゾ FLAC",
|
||||||
"@qualityHiResFlac": {
|
"@qualityHiResFlac": {
|
||||||
"description": "Quality option - high resolution FLAC"
|
"description": "Quality option - high resolution FLAC"
|
||||||
},
|
},
|
||||||
"qualityHiResFlacSubtitle": "24-bit / up to 96kHz",
|
"qualityHiResFlacSubtitle": "24-bit / 最大 96kHz",
|
||||||
"@qualityHiResFlacSubtitle": {
|
"@qualityHiResFlacSubtitle": {
|
||||||
"description": "Technical spec for hi-res"
|
"description": "Technical spec for hi-res"
|
||||||
},
|
},
|
||||||
"qualityHiResFlacMax": "Hi-Res FLAC Max",
|
"qualityHiResFlacMax": "ハイレゾ FLAC 最大",
|
||||||
"@qualityHiResFlacMax": {
|
"@qualityHiResFlacMax": {
|
||||||
"description": "Quality option - maximum resolution FLAC"
|
"description": "Quality option - maximum resolution FLAC"
|
||||||
},
|
},
|
||||||
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
|
"qualityHiResFlacMaxSubtitle": "24-bit / 最大 192kHz",
|
||||||
"@qualityHiResFlacMaxSubtitle": {
|
"@qualityHiResFlacMaxSubtitle": {
|
||||||
"description": "Technical spec for hi-res max"
|
"description": "Technical spec for hi-res max"
|
||||||
},
|
},
|
||||||
@@ -2334,11 +2336,11 @@
|
|||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
},
|
},
|
||||||
"downloadDirectory": "Download Directory",
|
"downloadDirectory": "ダウンロードディレクトリ",
|
||||||
"@downloadDirectory": {
|
"@downloadDirectory": {
|
||||||
"description": "Setting - download folder"
|
"description": "Setting - download folder"
|
||||||
},
|
},
|
||||||
"downloadSeparateSinglesFolder": "Separate Singles Folder",
|
"downloadSeparateSinglesFolder": "シングルのフォルダを分割",
|
||||||
"@downloadSeparateSinglesFolder": {
|
"@downloadSeparateSinglesFolder": {
|
||||||
"description": "Setting - separate folder for singles"
|
"description": "Setting - separate folder for singles"
|
||||||
},
|
},
|
||||||
@@ -2422,11 +2424,11 @@
|
|||||||
"@serviceSpotify": {
|
"@serviceSpotify": {
|
||||||
"description": "Service name - DO NOT TRANSLATE"
|
"description": "Service name - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
"appearanceAmoledDark": "AMOLED Dark",
|
"appearanceAmoledDark": "AMOLED ダーク",
|
||||||
"@appearanceAmoledDark": {
|
"@appearanceAmoledDark": {
|
||||||
"description": "Theme option - pure black"
|
"description": "Theme option - pure black"
|
||||||
},
|
},
|
||||||
"appearanceAmoledDarkSubtitle": "Pure black background",
|
"appearanceAmoledDarkSubtitle": "ピュアブラックの背景",
|
||||||
"@appearanceAmoledDarkSubtitle": {
|
"@appearanceAmoledDarkSubtitle": {
|
||||||
"description": "Subtitle for AMOLED dark"
|
"description": "Subtitle for AMOLED dark"
|
||||||
},
|
},
|
||||||
@@ -2434,15 +2436,15 @@
|
|||||||
"@appearanceChooseAccentColor": {
|
"@appearanceChooseAccentColor": {
|
||||||
"description": "Color picker dialog title"
|
"description": "Color picker dialog title"
|
||||||
},
|
},
|
||||||
"appearanceChooseTheme": "Theme Mode",
|
"appearanceChooseTheme": "テーマモード",
|
||||||
"@appearanceChooseTheme": {
|
"@appearanceChooseTheme": {
|
||||||
"description": "Theme picker dialog title"
|
"description": "Theme picker dialog title"
|
||||||
},
|
},
|
||||||
"queueTitle": "Download Queue",
|
"queueTitle": "ダウンロードキュー",
|
||||||
"@queueTitle": {
|
"@queueTitle": {
|
||||||
"description": "Queue screen title"
|
"description": "Queue screen title"
|
||||||
},
|
},
|
||||||
"queueClearAll": "Clear All",
|
"queueClearAll": "すべて消去",
|
||||||
"@queueClearAll": {
|
"@queueClearAll": {
|
||||||
"description": "Button - clear all queue items"
|
"description": "Button - clear all queue items"
|
||||||
},
|
},
|
||||||
@@ -2573,5 +2575,41 @@
|
|||||||
"utilityFunctions": "Utility Functions",
|
"utilityFunctions": "Utility Functions",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
|
},
|
||||||
|
"recentTypeArtist": "Artist",
|
||||||
|
"@recentTypeArtist": {
|
||||||
|
"description": "Recent access item type - artist"
|
||||||
|
},
|
||||||
|
"recentTypeAlbum": "Album",
|
||||||
|
"@recentTypeAlbum": {
|
||||||
|
"description": "Recent access item type - album"
|
||||||
|
},
|
||||||
|
"recentTypeSong": "Song",
|
||||||
|
"@recentTypeSong": {
|
||||||
|
"description": "Recent access item type - song/track"
|
||||||
|
},
|
||||||
|
"recentTypePlaylist": "Playlist",
|
||||||
|
"@recentTypePlaylist": {
|
||||||
|
"description": "Recent access item type - playlist"
|
||||||
|
},
|
||||||
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
|
"@recentPlaylistInfo": {
|
||||||
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Playlist name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorGeneric": "Error: {message}",
|
||||||
|
"@errorGeneric": {
|
||||||
|
"description": "Generic error message format",
|
||||||
|
"placeholders": {
|
||||||
|
"message": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Error message"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+613
-575
File diff suppressed because it is too large
Load Diff
+53
-15
@@ -642,6 +642,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artistPopular": "Popular",
|
||||||
|
"@artistPopular": {
|
||||||
|
"description": "Section header for popular/top tracks"
|
||||||
|
},
|
||||||
|
"artistMonthlyListeners": "{count} monthly listeners",
|
||||||
|
"@artistMonthlyListeners": {
|
||||||
|
"description": "Monthly listener count display",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Formatted listener count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Track Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
@@ -1851,27 +1865,15 @@
|
|||||||
},
|
},
|
||||||
"sectionLanguage": "Language",
|
"sectionLanguage": "Language",
|
||||||
"@sectionLanguage": {
|
"@sectionLanguage": {
|
||||||
"description": "Settings section header for language selection"
|
"description": "Settings section header for language"
|
||||||
},
|
},
|
||||||
"appearanceLanguage": "App Language",
|
"appearanceLanguage": "App Language",
|
||||||
"@appearanceLanguage": {
|
"@appearanceLanguage": {
|
||||||
"description": "Setting title for language selection"
|
"description": "Language setting title"
|
||||||
},
|
},
|
||||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||||
"@appearanceLanguageSubtitle": {
|
"@appearanceLanguageSubtitle": {
|
||||||
"description": "Subtitle for language setting"
|
"description": "Language setting subtitle"
|
||||||
},
|
|
||||||
"languageSystem": "System Default",
|
|
||||||
"@languageSystem": {
|
|
||||||
"description": "Use device system language"
|
|
||||||
},
|
|
||||||
"languageEnglish": "English",
|
|
||||||
"@languageEnglish": {
|
|
||||||
"description": "English language option"
|
|
||||||
},
|
|
||||||
"languageIndonesian": "Bahasa Indonesia",
|
|
||||||
"@languageIndonesian": {
|
|
||||||
"description": "Indonesian language option"
|
|
||||||
},
|
},
|
||||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||||
"@settingsAppearanceSubtitle": {
|
"@settingsAppearanceSubtitle": {
|
||||||
@@ -2573,5 +2575,41 @@
|
|||||||
"utilityFunctions": "Utility Functions",
|
"utilityFunctions": "Utility Functions",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
|
},
|
||||||
|
"recentTypeArtist": "Artist",
|
||||||
|
"@recentTypeArtist": {
|
||||||
|
"description": "Recent access item type - artist"
|
||||||
|
},
|
||||||
|
"recentTypeAlbum": "Album",
|
||||||
|
"@recentTypeAlbum": {
|
||||||
|
"description": "Recent access item type - album"
|
||||||
|
},
|
||||||
|
"recentTypeSong": "Song",
|
||||||
|
"@recentTypeSong": {
|
||||||
|
"description": "Recent access item type - song/track"
|
||||||
|
},
|
||||||
|
"recentTypePlaylist": "Playlist",
|
||||||
|
"@recentTypePlaylist": {
|
||||||
|
"description": "Recent access item type - playlist"
|
||||||
|
},
|
||||||
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
|
"@recentPlaylistInfo": {
|
||||||
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Playlist name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorGeneric": "Error: {message}",
|
||||||
|
"@errorGeneric": {
|
||||||
|
"description": "Generic error message format",
|
||||||
|
"placeholders": {
|
||||||
|
"message": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Error message"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -642,6 +642,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artistPopular": "Popular",
|
||||||
|
"@artistPopular": {
|
||||||
|
"description": "Section header for popular/top tracks"
|
||||||
|
},
|
||||||
|
"artistMonthlyListeners": "{count} monthly listeners",
|
||||||
|
"@artistMonthlyListeners": {
|
||||||
|
"description": "Monthly listener count display",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Formatted listener count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Track Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
@@ -1849,6 +1863,18 @@
|
|||||||
"@sectionLayout": {
|
"@sectionLayout": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
|
"sectionLanguage": "Language",
|
||||||
|
"@sectionLanguage": {
|
||||||
|
"description": "Settings section header for language"
|
||||||
|
},
|
||||||
|
"appearanceLanguage": "App Language",
|
||||||
|
"@appearanceLanguage": {
|
||||||
|
"description": "Language setting title"
|
||||||
|
},
|
||||||
|
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||||
|
"@appearanceLanguageSubtitle": {
|
||||||
|
"description": "Language setting subtitle"
|
||||||
|
},
|
||||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||||
"@settingsAppearanceSubtitle": {
|
"@settingsAppearanceSubtitle": {
|
||||||
"description": "Appearance settings description"
|
"description": "Appearance settings description"
|
||||||
@@ -2549,5 +2575,41 @@
|
|||||||
"utilityFunctions": "Utility Functions",
|
"utilityFunctions": "Utility Functions",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
|
},
|
||||||
|
"recentTypeArtist": "Artist",
|
||||||
|
"@recentTypeArtist": {
|
||||||
|
"description": "Recent access item type - artist"
|
||||||
|
},
|
||||||
|
"recentTypeAlbum": "Album",
|
||||||
|
"@recentTypeAlbum": {
|
||||||
|
"description": "Recent access item type - album"
|
||||||
|
},
|
||||||
|
"recentTypeSong": "Song",
|
||||||
|
"@recentTypeSong": {
|
||||||
|
"description": "Recent access item type - song/track"
|
||||||
|
},
|
||||||
|
"recentTypePlaylist": "Playlist",
|
||||||
|
"@recentTypePlaylist": {
|
||||||
|
"description": "Recent access item type - playlist"
|
||||||
|
},
|
||||||
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
|
"@recentPlaylistInfo": {
|
||||||
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Playlist name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorGeneric": "Error: {message}",
|
||||||
|
"@errorGeneric": {
|
||||||
|
"description": "Generic error message format",
|
||||||
|
"placeholders": {
|
||||||
|
"message": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Error message"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,48 @@
|
|||||||
// GENERATED FILE - DO NOT EDIT
|
// GENERATED FILE - DO NOT EDIT
|
||||||
// Generated by: dart run tool/check_translations.dart 70
|
// Generated by: dart run tool/check_translations.dart 0
|
||||||
// Only languages with >= 70% translation completion are included.
|
// Only languages with >= 0% translation completion are included.
|
||||||
// Translation is measured by comparing VALUES (not just key existence).
|
// Translation is measured by comparing VALUES (not just key existence).
|
||||||
//
|
//
|
||||||
// To regenerate, run: dart run tool/check_translations.dart 70
|
// To regenerate, run: dart run tool/check_translations.dart 0
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
/// Minimum translation completion threshold used to filter languages.
|
/// Minimum translation completion threshold used to filter languages.
|
||||||
const int translationThreshold = 70;
|
const int translationThreshold = 0;
|
||||||
|
|
||||||
/// List of locales that meet the translation threshold.
|
/// List of locales that meet the translation threshold.
|
||||||
/// Only these languages will be available in the app.
|
/// Only these languages will be available in the app.
|
||||||
const List<Locale> filteredSupportedLocales = <Locale>[
|
const List<Locale> filteredSupportedLocales = <Locale>[
|
||||||
Locale('en'),
|
Locale('en'),
|
||||||
|
Locale('ru'),
|
||||||
Locale('id'),
|
Locale('id'),
|
||||||
|
Locale('ja'),
|
||||||
|
Locale('de'),
|
||||||
|
Locale('es'),
|
||||||
|
Locale('fr'),
|
||||||
|
Locale('hi'),
|
||||||
|
Locale('ko'),
|
||||||
|
Locale('nl'),
|
||||||
|
Locale('pt'),
|
||||||
|
Locale('zh'),
|
||||||
|
Locale('zh', 'CN'),
|
||||||
|
Locale('zh', 'TW'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Set of locale codes for quick lookup.
|
/// Set of locale codes for quick lookup.
|
||||||
const Set<String> filteredLocaleCodes = <String>{
|
const Set<String> filteredLocaleCodes = <String>{
|
||||||
'en',
|
'en',
|
||||||
|
'ru',
|
||||||
'id',
|
'id',
|
||||||
|
'ja',
|
||||||
|
'de',
|
||||||
|
'es',
|
||||||
|
'fr',
|
||||||
|
'hi',
|
||||||
|
'ko',
|
||||||
|
'nl',
|
||||||
|
'pt',
|
||||||
|
'zh',
|
||||||
|
'zh_CN',
|
||||||
|
'zh_TW',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ import 'package:spotiflac_android/services/share_intent_service.dart';
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// Initialize notification service
|
|
||||||
await NotificationService().initialize();
|
await NotificationService().initialize();
|
||||||
|
|
||||||
// Initialize share intent service
|
|
||||||
await ShareIntentService().initialize();
|
await ShareIntentService().initialize();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
@@ -48,11 +46,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
final extensionsDir = '${appDir.path}/extensions';
|
final extensionsDir = '${appDir.path}/extensions';
|
||||||
final dataDir = '${appDir.path}/extension_data';
|
final dataDir = '${appDir.path}/extension_data';
|
||||||
|
|
||||||
// Create directories if needed
|
|
||||||
await Directory(extensionsDir).create(recursive: true);
|
await Directory(extensionsDir).create(recursive: true);
|
||||||
await Directory(dataDir).create(recursive: true);
|
await Directory(dataDir).create(recursive: true);
|
||||||
|
|
||||||
// Initialize extension system
|
|
||||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to initialize extensions: $e');
|
debugPrint('Failed to initialize extensions: $e');
|
||||||
@@ -61,7 +57,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Eagerly initialize download history provider to load from storage
|
|
||||||
ref.watch(downloadHistoryProvider);
|
ref.watch(downloadHistoryProvider);
|
||||||
return widget.child;
|
return widget.child;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ String? _normalizeOptionalString(String? value) {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download History Item model
|
|
||||||
class DownloadHistoryItem {
|
class DownloadHistoryItem {
|
||||||
final String id;
|
final String id;
|
||||||
final String trackName;
|
final String trackName;
|
||||||
@@ -37,7 +36,6 @@ class DownloadHistoryItem {
|
|||||||
final String filePath;
|
final String filePath;
|
||||||
final String service;
|
final String service;
|
||||||
final DateTime downloadedAt;
|
final DateTime downloadedAt;
|
||||||
// Additional metadata
|
|
||||||
final String? isrc;
|
final String? isrc;
|
||||||
final String? spotifyId;
|
final String? spotifyId;
|
||||||
final int? trackNumber;
|
final int? trackNumber;
|
||||||
@@ -45,7 +43,6 @@ class DownloadHistoryItem {
|
|||||||
final int? duration;
|
final int? duration;
|
||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
final String? quality;
|
final String? quality;
|
||||||
// Audio quality info (from file after download)
|
|
||||||
final int? bitDepth;
|
final int? bitDepth;
|
||||||
final int? sampleRate;
|
final int? sampleRate;
|
||||||
|
|
||||||
@@ -114,7 +111,6 @@ class DownloadHistoryItem {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download History State
|
|
||||||
class DownloadHistoryState {
|
class DownloadHistoryState {
|
||||||
final List<DownloadHistoryItem> items;
|
final List<DownloadHistoryItem> items;
|
||||||
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
||||||
@@ -134,14 +130,12 @@ class DownloadHistoryState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download History Notifier (Riverpod 3.x)
|
|
||||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||||
static const _storageKey = 'download_history';
|
static const _storageKey = 'download_history';
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DownloadHistoryState build() {
|
DownloadHistoryState build() {
|
||||||
// Load history from storage on init
|
|
||||||
_loadFromStorageSync();
|
_loadFromStorageSync();
|
||||||
return DownloadHistoryState();
|
return DownloadHistoryState();
|
||||||
}
|
}
|
||||||
@@ -165,13 +159,11 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
|
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Deduplicate existing history on load
|
|
||||||
final deduplicatedItems = _deduplicateHistory(items);
|
final deduplicatedItems = _deduplicateHistory(items);
|
||||||
|
|
||||||
state = state.copyWith(items: deduplicatedItems);
|
state = state.copyWith(items: deduplicatedItems);
|
||||||
_historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
|
_historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
|
||||||
|
|
||||||
// Save if duplicates were removed
|
|
||||||
if (deduplicatedItems.length < items.length) {
|
if (deduplicatedItems.length < items.length) {
|
||||||
_historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
|
_historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
|
||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
@@ -194,9 +186,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
final item = items[i];
|
final item = items[i];
|
||||||
String? key;
|
String? key;
|
||||||
|
|
||||||
// Generate unique key based on available identifiers
|
|
||||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||||
// Extract numeric ID for deezer: prefixed IDs
|
|
||||||
if (item.spotifyId!.startsWith('deezer:')) {
|
if (item.spotifyId!.startsWith('deezer:')) {
|
||||||
key = 'deezer:${item.spotifyId!.substring(7)}';
|
key = 'deezer:${item.spotifyId!.substring(7)}';
|
||||||
} else {
|
} else {
|
||||||
@@ -208,15 +198,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
|
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
if (!seen.containsKey(key)) {
|
if (!seen.containsKey(key)) {
|
||||||
// First occurrence - keep it (most recent since list is sorted by date desc)
|
|
||||||
seen[key] = result.length;
|
seen[key] = result.length;
|
||||||
result.add(item);
|
result.add(item);
|
||||||
} else {
|
} else {
|
||||||
// Duplicate found - skip (keep the first/most recent one)
|
|
||||||
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
|
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No identifier - keep it (can't deduplicate)
|
|
||||||
result.add(item);
|
result.add(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,26 +228,22 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void addToHistory(DownloadHistoryItem item) {
|
void addToHistory(DownloadHistoryItem item) {
|
||||||
// Check if track already exists in history (by spotifyId, deezerId, or ISRC)
|
|
||||||
final existingIndex = state.items.indexWhere((existing) {
|
final existingIndex = state.items.indexWhere((existing) {
|
||||||
// Match by spotifyId (primary identifier - includes deezer:xxx format)
|
|
||||||
if (item.spotifyId != null &&
|
if (item.spotifyId != null &&
|
||||||
item.spotifyId!.isNotEmpty &&
|
item.spotifyId!.isNotEmpty &&
|
||||||
existing.spotifyId == item.spotifyId) {
|
existing.spotifyId == item.spotifyId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match Deezer tracks: extract numeric ID from "deezer:123456" format
|
|
||||||
if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
|
if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
|
||||||
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
|
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
|
||||||
final itemDeezerId = item.spotifyId!.substring(7); // Remove "deezer:" prefix
|
final itemDeezerId = item.spotifyId!.substring(7);
|
||||||
final existingDeezerId = existing.spotifyId!.substring(7);
|
final existingDeezerId = existing.spotifyId!.substring(7);
|
||||||
if (itemDeezerId == existingDeezerId) {
|
if (itemDeezerId == existingDeezerId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: match by ISRC if spotifyId not available
|
|
||||||
if (item.isrc != null &&
|
if (item.isrc != null &&
|
||||||
item.isrc!.isNotEmpty &&
|
item.isrc!.isNotEmpty &&
|
||||||
existing.isrc == item.isrc) {
|
existing.isrc == item.isrc) {
|
||||||
@@ -270,16 +253,13 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
// Replace existing entry (update with new download info)
|
|
||||||
final updatedItems = [...state.items];
|
final updatedItems = [...state.items];
|
||||||
updatedItems[existingIndex] = item;
|
updatedItems[existingIndex] = item;
|
||||||
// Move to top of list (most recent)
|
|
||||||
updatedItems.removeAt(existingIndex);
|
updatedItems.removeAt(existingIndex);
|
||||||
updatedItems.insert(0, item);
|
updatedItems.insert(0, item);
|
||||||
state = state.copyWith(items: updatedItems);
|
state = state.copyWith(items: updatedItems);
|
||||||
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
||||||
} else {
|
} else {
|
||||||
// Add new entry
|
|
||||||
state = state.copyWith(items: [item, ...state.items]);
|
state = state.copyWith(items: [item, ...state.items]);
|
||||||
_historyLog.d('Added new history entry: ${item.trackName}');
|
_historyLog.d('Added new history entry: ${item.trackName}');
|
||||||
}
|
}
|
||||||
@@ -313,7 +293,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download History Provider
|
|
||||||
final downloadHistoryProvider =
|
final downloadHistoryProvider =
|
||||||
NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
|
NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
|
||||||
DownloadHistoryNotifier.new,
|
DownloadHistoryNotifier.new,
|
||||||
@@ -381,7 +360,6 @@ class DownloadQueueState {
|
|||||||
items.where((i) => i.status == DownloadStatus.downloading).length;
|
items.where((i) => i.status == DownloadStatus.downloading).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download Queue Notifier (Riverpod 3.x)
|
|
||||||
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
int _downloadCount = 0; // Counter for connection cleanup
|
int _downloadCount = 0; // Counter for connection cleanup
|
||||||
@@ -396,13 +374,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
DownloadQueueState build() {
|
DownloadQueueState build() {
|
||||||
// Cleanup timer when provider is disposed
|
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = null;
|
_progressTimer = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize output directory and load persisted queue asynchronously
|
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
await _initOutputDir();
|
await _initOutputDir();
|
||||||
await _loadQueueFromStorage();
|
await _loadQueueFromStorage();
|
||||||
@@ -424,7 +400,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>))
|
.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Reset downloading items to queued (they were interrupted)
|
|
||||||
final restoredItems = items.map((item) {
|
final restoredItems = items.map((item) {
|
||||||
if (item.status == DownloadStatus.downloading) {
|
if (item.status == DownloadStatus.downloading) {
|
||||||
return item.copyWith(status: DownloadStatus.queued, progress: 0);
|
return item.copyWith(status: DownloadStatus.queued, progress: 0);
|
||||||
@@ -432,7 +407,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return item;
|
return item;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Only restore queued/downloading items (not completed/failed/skipped)
|
|
||||||
final pendingItems = restoredItems
|
final pendingItems = restoredItems
|
||||||
.where((item) => item.status == DownloadStatus.queued)
|
.where((item) => item.status == DownloadStatus.queued)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -441,11 +415,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
state = state.copyWith(items: pendingItems);
|
state = state.copyWith(items: pendingItems);
|
||||||
_log.i('Restored ${pendingItems.length} pending items from storage');
|
_log.i('Restored ${pendingItems.length} pending items from storage');
|
||||||
|
|
||||||
// Auto-resume queue processing
|
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
} else {
|
} else {
|
||||||
_log.d('No pending items to restore');
|
_log.d('No pending items to restore');
|
||||||
// Clear storage since nothing to restore
|
|
||||||
await prefs.remove(_queueStorageKey);
|
await prefs.remove(_queueStorageKey);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -461,7 +433,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Only persist queued and downloading items
|
|
||||||
final pendingItems = state.items
|
final pendingItems = state.items
|
||||||
.where(
|
.where(
|
||||||
(item) =>
|
(item) =>
|
||||||
@@ -471,7 +442,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (pendingItems.isEmpty) {
|
if (pendingItems.isEmpty) {
|
||||||
// Clear storage if no pending items
|
|
||||||
await prefs.remove(_queueStorageKey);
|
await prefs.remove(_queueStorageKey);
|
||||||
_log.d('Cleared queue storage (no pending items)');
|
_log.d('Cleared queue storage (no pending items)');
|
||||||
} else {
|
} else {
|
||||||
@@ -523,12 +493,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
itemProgress['is_downloading'] as bool? ?? false;
|
itemProgress['is_downloading'] as bool? ?? false;
|
||||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||||
|
|
||||||
// Check if status is "finalizing" (embedding metadata)
|
|
||||||
// Only trust finalizing status if bytesTotal > 0 (download actually happened)
|
|
||||||
if (status == 'finalizing' && bytesTotal > 0) {
|
if (status == 'finalizing' && bytesTotal > 0) {
|
||||||
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
||||||
|
|
||||||
// Track finalizing item for notification
|
|
||||||
final currentItem = state.items
|
final currentItem = state.items
|
||||||
.where((i) => i.id == itemId)
|
.where((i) => i.id == itemId)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
@@ -540,23 +507,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use progress from backend if available (handles both explicit progress and byte-based)
|
|
||||||
final progressFromBackend =
|
final progressFromBackend =
|
||||||
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
|
||||||
if (isDownloading) {
|
if (isDownloading) {
|
||||||
double percentage = 0.0;
|
double percentage = 0.0;
|
||||||
if (bytesTotal > 0) {
|
if (bytesTotal > 0) {
|
||||||
// Calculate from bytes if available for precision
|
|
||||||
percentage = bytesReceived / bytesTotal;
|
percentage = bytesReceived / bytesTotal;
|
||||||
} else {
|
} else {
|
||||||
// Fallback to backend-reported progress (e.g. for DASH segments)
|
|
||||||
percentage = progressFromBackend;
|
percentage = progressFromBackend;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProgress(itemId, percentage, speedMBps: speedMBps);
|
updateProgress(itemId, percentage, speedMBps: speedMBps);
|
||||||
|
|
||||||
// Log progress for each item with speed
|
|
||||||
final mbReceived = bytesReceived / (1024 * 1024);
|
final mbReceived = bytesReceived / (1024 * 1024);
|
||||||
final mbTotal = bytesTotal / (1024 * 1024);
|
final mbTotal = bytesTotal / (1024 * 1024);
|
||||||
if (bytesTotal > 0) {
|
if (bytesTotal > 0) {
|
||||||
@@ -571,7 +534,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show finalizing notification if any item is finalizing (takes priority)
|
|
||||||
if (hasFinalizingItem && finalizingTrackName != null) {
|
if (hasFinalizingItem && finalizingTrackName != null) {
|
||||||
_notificationService.showDownloadFinalizing(
|
_notificationService.showDownloadFinalizing(
|
||||||
trackName: finalizingTrackName,
|
trackName: finalizingTrackName,
|
||||||
@@ -580,19 +542,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return; // Don't show download progress notification
|
return; // Don't show download progress notification
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update notification with active downloads
|
|
||||||
if (items.isNotEmpty) {
|
if (items.isNotEmpty) {
|
||||||
final firstEntry = items.entries.first;
|
final firstEntry = items.entries.first;
|
||||||
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||||
|
|
||||||
// Find downloading items (not finalizing)
|
|
||||||
final downloadingItems = state.items
|
final downloadingItems = state.items
|
||||||
.where((i) => i.status == DownloadStatus.downloading)
|
.where((i) => i.status == DownloadStatus.downloading)
|
||||||
.toList();
|
.toList();
|
||||||
if (downloadingItems.isNotEmpty) {
|
if (downloadingItems.isNotEmpty) {
|
||||||
// Show single track name if only 1 download, otherwise show count
|
|
||||||
final trackName = downloadingItems.length == 1
|
final trackName = downloadingItems.length == 1
|
||||||
? downloadingItems.first.track.name
|
? downloadingItems.first.track.name
|
||||||
: '${downloadingItems.length} downloads';
|
: '${downloadingItems.length} downloads';
|
||||||
@@ -600,12 +559,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
? downloadingItems.first.track.artistName
|
? downloadingItems.first.track.artistName
|
||||||
: 'Downloading...';
|
: 'Downloading...';
|
||||||
|
|
||||||
// Calculate notification progress values
|
|
||||||
int notifProgress = bytesReceived;
|
int notifProgress = bytesReceived;
|
||||||
int notifTotal = bytesTotal;
|
int notifTotal = bytesTotal;
|
||||||
|
|
||||||
if (bytesTotal <= 0) {
|
if (bytesTotal <= 0) {
|
||||||
// Fallback to percentage for DASH/unknown size
|
|
||||||
final progressPercent =
|
final progressPercent =
|
||||||
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
notifProgress = (progressPercent * 100).toInt();
|
notifProgress = (progressPercent * 100).toInt();
|
||||||
@@ -616,10 +573,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
trackName: trackName,
|
trackName: trackName,
|
||||||
artistName: artistName,
|
artistName: artistName,
|
||||||
progress: notifProgress,
|
progress: notifProgress,
|
||||||
total: notifTotal > 0 ? notifTotal : 1,
|
total: notifTotal > 0 ? notifTotal : 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update foreground service notification (Android)
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
PlatformBridge.updateDownloadServiceProgress(
|
PlatformBridge.updateDownloadServiceProgress(
|
||||||
trackName: downloadingItems.first.track.name,
|
trackName: downloadingItems.first.track.name,
|
||||||
@@ -632,7 +588,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore polling errors
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -646,7 +601,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (state.outputDir.isEmpty) {
|
if (state.outputDir.isEmpty) {
|
||||||
try {
|
try {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
// iOS: Use Documents directory (accessible via Files app)
|
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
||||||
if (!await musicDir.exists()) {
|
if (!await musicDir.exists()) {
|
||||||
@@ -654,7 +608,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
state = state.copyWith(outputDir: musicDir.path);
|
state = state.copyWith(outputDir: musicDir.path);
|
||||||
} else {
|
} else {
|
||||||
// Android: Use external storage Music folder
|
|
||||||
final dir = await getExternalStorageDirectory();
|
final dir = await getExternalStorageDirectory();
|
||||||
if (dir != null) {
|
if (dir != null) {
|
||||||
final musicDir = Directory(
|
final musicDir = Directory(
|
||||||
@@ -665,7 +618,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
state = state.copyWith(outputDir: musicDir.path);
|
state = state.copyWith(outputDir: musicDir.path);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to documents directory
|
|
||||||
final docDir = await getApplicationDocumentsDirectory();
|
final docDir = await getApplicationDocumentsDirectory();
|
||||||
final musicDir = Directory('${docDir.path}/SpotiFLAC');
|
final musicDir = Directory('${docDir.path}/SpotiFLAC');
|
||||||
if (!await musicDir.exists()) {
|
if (!await musicDir.exists()) {
|
||||||
@@ -675,7 +627,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback for any platform
|
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
||||||
if (!await musicDir.exists()) {
|
if (!await musicDir.exists()) {
|
||||||
@@ -695,12 +646,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
String baseDir = state.outputDir;
|
String baseDir = state.outputDir;
|
||||||
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||||
|
|
||||||
// If separateSingles is enabled, use Albums/Singles structure
|
|
||||||
if (separateSingles) {
|
if (separateSingles) {
|
||||||
final isSingle = track.isSingle;
|
final isSingle = track.isSingle;
|
||||||
|
|
||||||
if (isSingle) {
|
if (isSingle) {
|
||||||
// Singles go to Singles folder (flat structure)
|
|
||||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||||
final dir = Directory(singlesPath);
|
final dir = Directory(singlesPath);
|
||||||
if (!await dir.exists()) {
|
if (!await dir.exists()) {
|
||||||
@@ -709,7 +658,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
return singlesPath;
|
return singlesPath;
|
||||||
} else {
|
} else {
|
||||||
// Albums folder structure based on setting
|
|
||||||
final albumName = _sanitizeFolderName(track.albumName);
|
final albumName = _sanitizeFolderName(track.albumName);
|
||||||
final artistName = _sanitizeFolderName(albumArtist);
|
final artistName = _sanitizeFolderName(albumArtist);
|
||||||
final year = _extractYear(track.releaseDate);
|
final year = _extractYear(track.releaseDate);
|
||||||
@@ -717,21 +665,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
switch (albumFolderStructure) {
|
switch (albumFolderStructure) {
|
||||||
case 'album_only':
|
case 'album_only':
|
||||||
// Albums/Album structure (no artist folder)
|
|
||||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
||||||
break;
|
break;
|
||||||
case 'artist_year_album':
|
case 'artist_year_album':
|
||||||
// Albums/Artist/[Year] Album structure
|
|
||||||
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
||||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
|
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
|
||||||
break;
|
break;
|
||||||
case 'year_album':
|
case 'year_album':
|
||||||
// Albums/[Year] Album structure (no artist folder)
|
|
||||||
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
||||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum';
|
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Albums/Artist/Album structure (default: artist_album)
|
|
||||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,7 +688,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Original folder organization logic (when separateSingles is disabled)
|
|
||||||
if (folderOrganization == 'none') {
|
if (folderOrganization == 'none') {
|
||||||
return baseDir;
|
return baseDir;
|
||||||
}
|
}
|
||||||
@@ -790,7 +733,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
/// Extract year from release date (format: "2005-06-13" or "2005")
|
/// Extract year from release date (format: "2005-06-13" or "2005")
|
||||||
String? _extractYear(String? releaseDate) {
|
String? _extractYear(String? releaseDate) {
|
||||||
if (releaseDate == null || releaseDate.isEmpty) return null;
|
if (releaseDate == null || releaseDate.isEmpty) return null;
|
||||||
// Handle both "2005-06-13" and "2005" formats
|
|
||||||
final match = RegExp(r'^(\d{4})').firstMatch(releaseDate);
|
final match = RegExp(r'^(\d{4})').firstMatch(releaseDate);
|
||||||
return match?.group(1);
|
return match?.group(1);
|
||||||
}
|
}
|
||||||
@@ -808,7 +750,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String addToQueue(Track track, String service, {String? qualityOverride}) {
|
String addToQueue(Track track, String service, {String? qualityOverride}) {
|
||||||
// Sync settings before adding to queue
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
|
|
||||||
@@ -823,10 +764,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
state = state.copyWith(items: [...state.items, item]);
|
state = state.copyWith(items: [...state.items, item]);
|
||||||
_saveQueueToStorage(); // Persist queue
|
_saveQueueToStorage();
|
||||||
|
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
// Run in microtask to not block UI
|
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,7 +778,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
String service, {
|
String service, {
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
}) {
|
}) {
|
||||||
// Sync settings before adding to queue
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
|
|
||||||
@@ -858,7 +797,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_saveQueueToStorage(); // Persist queue
|
_saveQueueToStorage(); // Persist queue
|
||||||
|
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
// Run in microtask to not block UI
|
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -888,7 +826,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
|
|
||||||
// Persist queue when status changes to completed/failed/skipped (item removed from pending)
|
|
||||||
if (status == DownloadStatus.completed ||
|
if (status == DownloadStatus.completed ||
|
||||||
status == DownloadStatus.failed ||
|
status == DownloadStatus.failed ||
|
||||||
status == DownloadStatus.skipped) {
|
status == DownloadStatus.skipped) {
|
||||||
@@ -951,7 +888,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (state.isPaused) {
|
if (state.isPaused) {
|
||||||
state = state.copyWith(isPaused: false);
|
state = state.copyWith(isPaused: false);
|
||||||
_log.i('Queue resumed');
|
_log.i('Queue resumed');
|
||||||
// If there are still queued items, continue processing
|
|
||||||
if (state.queuedCount > 0 && !state.isProcessing) {
|
if (state.queuedCount > 0 && !state.isProcessing) {
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
}
|
}
|
||||||
@@ -975,7 +911,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only retry if status is failed or skipped
|
|
||||||
if (item.status != DownloadStatus.failed &&
|
if (item.status != DownloadStatus.failed &&
|
||||||
item.status != DownloadStatus.skipped) {
|
item.status != DownloadStatus.skipped) {
|
||||||
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
||||||
@@ -995,9 +930,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return i;
|
return i;
|
||||||
}).toList();
|
}).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_saveQueueToStorage(); // Persist queue
|
_saveQueueToStorage();
|
||||||
|
|
||||||
// Start processing if not already running
|
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
_log.d('Starting queue processing for retry');
|
_log.d('Starting queue processing for retry');
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
@@ -1019,7 +953,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final extensionState = ref.read(extensionProvider);
|
final extensionState = ref.read(extensionProvider);
|
||||||
|
|
||||||
// Check if post-processing is enabled and there are extensions with hooks
|
|
||||||
if (!settings.useExtensionProviders) return;
|
if (!settings.useExtensionProviders) return;
|
||||||
|
|
||||||
final hasPostProcessing = extensionState.extensions.any(
|
final hasPostProcessing = extensionState.extensions.any(
|
||||||
@@ -1029,7 +962,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_log.d('Running post-processing hooks on: $filePath');
|
_log.d('Running post-processing hooks on: $filePath');
|
||||||
|
|
||||||
// Build metadata map for post-processing
|
|
||||||
final metadata = <String, dynamic>{
|
final metadata = <String, dynamic>{
|
||||||
'title': track.name,
|
'title': track.name,
|
||||||
'artist': track.artistName,
|
'artist': track.artistName,
|
||||||
@@ -1059,7 +991,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Post-processing error: $e');
|
_log.w('Post-processing error: $e');
|
||||||
// Don't fail the download if post-processing fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,15 +999,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||||
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
|
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
|
||||||
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
|
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
|
||||||
const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000)
|
const spotifySizeMax = 'ab67616d000082c1';
|
||||||
|
|
||||||
// First upgrade small (300) to medium (640)
|
|
||||||
var result = coverUrl;
|
var result = coverUrl;
|
||||||
if (result.contains(spotifySize300)) {
|
if (result.contains(spotifySize300)) {
|
||||||
result = result.replaceFirst(spotifySize300, spotifySize640);
|
result = result.replaceFirst(spotifySize300, spotifySize640);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then upgrade medium (640) to max
|
|
||||||
if (result.contains(spotifySize640)) {
|
if (result.contains(spotifySize640)) {
|
||||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||||
}
|
}
|
||||||
@@ -1088,12 +1017,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
// Download cover first
|
|
||||||
String? coverPath;
|
String? coverPath;
|
||||||
var coverUrl = track.coverUrl;
|
var coverUrl = track.coverUrl;
|
||||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
// Upgrade cover URL to max quality if setting is enabled
|
|
||||||
if (settings.maxQualityCover) {
|
if (settings.maxQualityCover) {
|
||||||
coverUrl = _upgradeToMaxQualityCover(coverUrl);
|
coverUrl = _upgradeToMaxQualityCover(coverUrl);
|
||||||
_log.d('Cover URL upgraded to max quality: $coverUrl');
|
_log.d('Cover URL upgraded to max quality: $coverUrl');
|
||||||
@@ -1104,7 +1031,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||||
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
|
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
|
||||||
|
|
||||||
// Download cover using HTTP
|
|
||||||
final httpClient = HttpClient();
|
final httpClient = HttpClient();
|
||||||
final request = await httpClient.getUrl(Uri.parse(coverUrl));
|
final request = await httpClient.getUrl(Uri.parse(coverUrl));
|
||||||
final response = await request.close();
|
final response = await request.close();
|
||||||
@@ -1125,12 +1051,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Go backend to embed metadata
|
|
||||||
try {
|
try {
|
||||||
// Use FFmpeg to embed cover art AND text metadata
|
|
||||||
// FFmpeg can embed cover art to FLAC and also set tags
|
|
||||||
|
|
||||||
// Construct metadata map
|
|
||||||
final metadata = <String, String>{
|
final metadata = <String, String>{
|
||||||
'TITLE': track.name,
|
'TITLE': track.name,
|
||||||
'ARTIST': track.artistName,
|
'ARTIST': track.artistName,
|
||||||
@@ -1162,15 +1083,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_log.d('Metadata map content: $metadata');
|
_log.d('Metadata map content: $metadata');
|
||||||
|
|
||||||
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
|
|
||||||
// Since we are in the Flutter context, we can call the bridge to get lyrics
|
|
||||||
// This ensures even converted files have lyrics embedded if available
|
|
||||||
try {
|
try {
|
||||||
|
// Convert duration from seconds to milliseconds for better lyrics matching
|
||||||
|
final durationMs = track.duration * 1000;
|
||||||
|
|
||||||
final lrcContent = await PlatformBridge.getLyricsLRC(
|
final lrcContent = await PlatformBridge.getLyricsLRC(
|
||||||
track.id, // spotifyID
|
track.id, // spotifyID
|
||||||
track.name,
|
track.name,
|
||||||
track.artistName,
|
track.artistName,
|
||||||
filePath: '', // No local file path yet (processed in memory)
|
filePath: '', // No local file path yet (processed in memory)
|
||||||
|
durationMs: durationMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (lrcContent.isNotEmpty) {
|
if (lrcContent.isNotEmpty) {
|
||||||
@@ -1184,8 +1106,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_log.d('Generating tags for FLAC: $metadata');
|
_log.d('Generating tags for FLAC: $metadata');
|
||||||
|
|
||||||
// Perform embedding (cover + text metadata)
|
|
||||||
// Note: FFmpegService.embedMetadata handles safe temp file creation
|
|
||||||
final result = await FFmpegService.embedMetadata(
|
final result = await FFmpegService.embedMetadata(
|
||||||
flacPath: flacPath,
|
flacPath: flacPath,
|
||||||
coverPath: coverPath != null && await File(coverPath).exists()
|
coverPath: coverPath != null && await File(coverPath).exists()
|
||||||
@@ -1200,14 +1120,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.w('FFmpeg metadata/cover embed failed');
|
_log.w('FFmpeg metadata/cover embed failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up cover file if it exists
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
try {
|
try {
|
||||||
final coverFile = File(coverPath);
|
final coverFile = File(coverPath);
|
||||||
if (await coverFile.exists()) {
|
if (await coverFile.exists()) {
|
||||||
// In Android 10+ scoped storage, we can't easily delete if we didn't create it
|
|
||||||
// in this session or if it's not in our app dir.
|
|
||||||
// But coverPath is typically in temp dir now.
|
|
||||||
await coverFile.delete();
|
await coverFile.delete();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -1223,14 +1139,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
state = state.copyWith(isProcessing: true);
|
state = state.copyWith(isProcessing: true);
|
||||||
_log.i('Starting queue processing...');
|
_log.i('Starting queue processing...');
|
||||||
|
|
||||||
// Track total items at start for notification
|
|
||||||
_totalQueuedAtStart = state.items
|
_totalQueuedAtStart = state.items
|
||||||
.where((i) => i.status == DownloadStatus.queued)
|
.where((i) => i.status == DownloadStatus.queued)
|
||||||
.length;
|
.length;
|
||||||
_completedInSession = 0;
|
_completedInSession = 0;
|
||||||
_failedInSession = 0;
|
_failedInSession = 0;
|
||||||
|
|
||||||
// Start foreground service to keep downloads running in background (Android only)
|
|
||||||
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
||||||
final firstItem = state.items.firstWhere(
|
final firstItem = state.items.firstWhere(
|
||||||
(item) => item.status == DownloadStatus.queued,
|
(item) => item.status == DownloadStatus.queued,
|
||||||
@@ -1248,13 +1162,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure output directory is initialized before processing
|
|
||||||
if (state.outputDir.isEmpty) {
|
if (state.outputDir.isEmpty) {
|
||||||
_log.d('Output dir empty, initializing...');
|
_log.d('Output dir empty, initializing...');
|
||||||
await _initOutputDir();
|
await _initOutputDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still empty, use fallback
|
|
||||||
if (state.outputDir.isEmpty) {
|
if (state.outputDir.isEmpty) {
|
||||||
_log.d('Using fallback directory...');
|
_log.d('Using fallback directory...');
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
@@ -1268,7 +1180,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.d('Output directory: ${state.outputDir}');
|
_log.d('Output directory: ${state.outputDir}');
|
||||||
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
||||||
|
|
||||||
// Use parallel processing if concurrentDownloads > 1
|
|
||||||
if (state.concurrentDownloads > 1) {
|
if (state.concurrentDownloads > 1) {
|
||||||
await _processQueueParallel();
|
await _processQueueParallel();
|
||||||
} else {
|
} else {
|
||||||
@@ -1277,7 +1188,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
|
|
||||||
// Stop foreground service (Android only)
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.stopDownloadService();
|
await PlatformBridge.stopDownloadService();
|
||||||
@@ -1287,7 +1197,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final cleanup after queue finishes
|
|
||||||
if (_downloadCount > 0) {
|
if (_downloadCount > 0) {
|
||||||
_log.d('Final connection cleanup...');
|
_log.d('Final connection cleanup...');
|
||||||
try {
|
try {
|
||||||
@@ -1298,7 +1207,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_downloadCount = 0;
|
_downloadCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show queue completion notification
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
||||||
);
|
);
|
||||||
@@ -1312,7 +1220,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.i('Queue processing finished');
|
_log.i('Queue processing finished');
|
||||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||||
|
|
||||||
// Check if there are new queued items (e.g., from retry) and restart if needed
|
|
||||||
final hasQueuedItems = state.items.any(
|
final hasQueuedItems = state.items.any(
|
||||||
(item) => item.status == DownloadStatus.queued,
|
(item) => item.status == DownloadStatus.queued,
|
||||||
);
|
);
|
||||||
@@ -1326,18 +1233,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
/// Sequential download processing (uses multi-progress system with single item)
|
/// Sequential download processing (uses multi-progress system with single item)
|
||||||
Future<void> _processQueueSequential() async {
|
Future<void> _processQueueSequential() async {
|
||||||
// Start multi-progress polling (works for both sequential and parallel)
|
|
||||||
_startMultiProgressPolling();
|
_startMultiProgressPolling();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Check if paused
|
|
||||||
if (state.isPaused) {
|
if (state.isPaused) {
|
||||||
_log.d('Queue is paused, waiting...');
|
_log.d('Queue is paused, waiting...');
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-read state to get latest items (important for retry)
|
|
||||||
final currentItems = state.items;
|
final currentItems = state.items;
|
||||||
final nextItem = currentItems.firstWhere(
|
final nextItem = currentItems.firstWhere(
|
||||||
(item) => item.status == DownloadStatus.queued,
|
(item) => item.status == DownloadStatus.queued,
|
||||||
@@ -1367,11 +1271,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
await _downloadSingleItem(nextItem);
|
await _downloadSingleItem(nextItem);
|
||||||
|
|
||||||
// Clear item progress after download completes
|
|
||||||
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
|
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop polling when queue is done
|
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1380,11 +1282,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final maxConcurrent = state.concurrentDownloads;
|
final maxConcurrent = state.concurrentDownloads;
|
||||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
||||||
|
|
||||||
// Start multi-progress polling (shared with sequential mode)
|
|
||||||
_startMultiProgressPolling();
|
_startMultiProgressPolling();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Check if paused - don't start new downloads but let active ones finish
|
|
||||||
if (state.isPaused) {
|
if (state.isPaused) {
|
||||||
_log.d('Queue is paused, waiting for active downloads...');
|
_log.d('Queue is paused, waiting for active downloads...');
|
||||||
if (activeDownloads.isNotEmpty) {
|
if (activeDownloads.isNotEmpty) {
|
||||||
@@ -1395,7 +1295,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get queued items
|
|
||||||
final queuedItems = state.items
|
final queuedItems = state.items
|
||||||
.where((item) => item.status == DownloadStatus.queued)
|
.where((item) => item.status == DownloadStatus.queued)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -1405,19 +1304,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start new downloads up to max concurrent limit
|
|
||||||
while (activeDownloads.length < maxConcurrent &&
|
while (activeDownloads.length < maxConcurrent &&
|
||||||
queuedItems.isNotEmpty &&
|
queuedItems.isNotEmpty &&
|
||||||
!state.isPaused) {
|
!state.isPaused) {
|
||||||
final item = queuedItems.removeAt(0);
|
final item = queuedItems.removeAt(0);
|
||||||
|
|
||||||
// Mark as downloading immediately to prevent double-processing
|
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||||
|
|
||||||
// Create the download future
|
|
||||||
final future = _downloadSingleItem(item).whenComplete(() {
|
final future = _downloadSingleItem(item).whenComplete(() {
|
||||||
activeDownloads.remove(item.id);
|
activeDownloads.remove(item.id);
|
||||||
// Clear item progress after download completes
|
|
||||||
PlatformBridge.clearItemProgress(item.id).catchError((_) {});
|
PlatformBridge.clearItemProgress(item.id).catchError((_) {});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1427,18 +1322,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for at least one download to complete before checking for more
|
|
||||||
if (activeDownloads.isNotEmpty) {
|
if (activeDownloads.isNotEmpty) {
|
||||||
await Future.any(activeDownloads.values);
|
await Future.any(activeDownloads.values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all remaining downloads to complete
|
|
||||||
if (activeDownloads.isNotEmpty) {
|
if (activeDownloads.isNotEmpty) {
|
||||||
await Future.wait(activeDownloads.values);
|
await Future.wait(activeDownloads.values);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop polling when queue is done
|
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1456,21 +1348,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set currentDownload for UI reference
|
|
||||||
state = state.copyWith(currentDownload: item);
|
state = state.copyWith(currentDownload: item);
|
||||||
|
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get folder organization setting and build output directory
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
// Metadata Enrichment:
|
|
||||||
// If track number is missing/0 (common from Search results), fetch full metadata
|
|
||||||
// This ensures the downloaded file has correct tags (Track, Disc, Year)
|
|
||||||
Track trackToDownload = item.track;
|
Track trackToDownload = item.track;
|
||||||
// Enrich metadata if ISRC or track number is missing (common from Search results)
|
|
||||||
// ISRC is critical for accurate track matching on streaming services
|
|
||||||
final needsEnrichment =
|
final needsEnrichment =
|
||||||
trackToDownload.id.startsWith('deezer:') &&
|
trackToDownload.id.startsWith('deezer:') &&
|
||||||
(trackToDownload.isrc == null ||
|
(trackToDownload.isrc == null ||
|
||||||
@@ -1495,7 +1380,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.d('Got response keys: ${fullData.keys.toList()}');
|
_log.d('Got response keys: ${fullData.keys.toList()}');
|
||||||
|
|
||||||
if (fullData.containsKey('track')) {
|
if (fullData.containsKey('track')) {
|
||||||
// Parse Go backend response (snake_case) to Track
|
|
||||||
final trackData = fullData['track'];
|
final trackData = fullData['track'];
|
||||||
_log.d('Track data type: ${trackData.runtimeType}');
|
_log.d('Track data type: ${trackData.runtimeType}');
|
||||||
if (trackData is Map<String, dynamic>) {
|
if (trackData is Map<String, dynamic>) {
|
||||||
@@ -1524,7 +1408,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
deezerId: rawId,
|
deezerId: rawId,
|
||||||
availability: trackToDownload.availability,
|
availability: trackToDownload.availability,
|
||||||
// Preserve albumType from API response or original track
|
|
||||||
albumType: (data['album_type'] as String?) ?? trackToDownload.albumType,
|
albumType: (data['album_type'] as String?) ?? trackToDownload.albumType,
|
||||||
source: trackToDownload.source,
|
source: trackToDownload.source,
|
||||||
);
|
);
|
||||||
@@ -1543,7 +1426,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log cover URL for debugging CSV import issues
|
|
||||||
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||||
|
|
||||||
final normalizedAlbumArtist =
|
final normalizedAlbumArtist =
|
||||||
@@ -1556,18 +1438,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
albumFolderStructure: settings.albumFolderStructure,
|
albumFolderStructure: settings.albumFolderStructure,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use quality override if set, otherwise use default from settings
|
|
||||||
final quality = item.qualityOverride ?? state.audioQuality;
|
final quality = item.qualityOverride ?? state.audioQuality;
|
||||||
|
|
||||||
Map<String, dynamic> result;
|
Map<String, dynamic> result;
|
||||||
|
|
||||||
// Check if extension providers should be used
|
|
||||||
final extensionState = ref.read(extensionProvider);
|
final extensionState = ref.read(extensionProvider);
|
||||||
final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled);
|
final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled);
|
||||||
final useExtensions = settings.useExtensionProviders && hasActiveExtensions;
|
final useExtensions = settings.useExtensionProviders && hasActiveExtensions;
|
||||||
|
|
||||||
if (useExtensions) {
|
if (useExtensions) {
|
||||||
// Use extension providers (includes fallback to built-in services)
|
|
||||||
_log.d('Using extension providers for download');
|
_log.d('Using extension providers for download');
|
||||||
_log.d(
|
_log.d(
|
||||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||||
@@ -1640,14 +1519,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_log.d('Result: $result');
|
_log.d('Result: $result');
|
||||||
|
|
||||||
// Check if item was cancelled while downloading
|
|
||||||
final currentItem = state.items.firstWhere(
|
final currentItem = state.items.firstWhere(
|
||||||
(i) => i.id == item.id,
|
(i) => i.id == item.id,
|
||||||
orElse: () => item,
|
orElse: () => item,
|
||||||
);
|
);
|
||||||
if (currentItem.status == DownloadStatus.skipped) {
|
if (currentItem.status == DownloadStatus.skipped) {
|
||||||
_log.i('Download was cancelled, skipping result processing');
|
_log.i('Download was cancelled, skipping result processing');
|
||||||
// Delete the downloaded file if it exists
|
|
||||||
final filePath = result['file_path'] as String?;
|
final filePath = result['file_path'] as String?;
|
||||||
if (filePath != null && result['success'] == true) {
|
if (filePath != null && result['success'] == true) {
|
||||||
try {
|
try {
|
||||||
@@ -1666,14 +1543,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (result['success'] == true) {
|
if (result['success'] == true) {
|
||||||
var filePath = result['file_path'] as String?;
|
var filePath = result['file_path'] as String?;
|
||||||
|
|
||||||
// Strip EXISTS: prefix from duplicate detection
|
|
||||||
if (filePath != null && filePath.startsWith('EXISTS:')) {
|
if (filePath != null && filePath.startsWith('EXISTS:')) {
|
||||||
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
|
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Download success, file: $filePath');
|
_log.i('Download success, file: $filePath');
|
||||||
|
|
||||||
// Get actual quality from response (if available)
|
|
||||||
final actualBitDepth = result['actual_bit_depth'] as int?;
|
final actualBitDepth = result['actual_bit_depth'] as int?;
|
||||||
final actualSampleRate = result['actual_sample_rate'] as int?;
|
final actualSampleRate = result['actual_sample_rate'] as int?;
|
||||||
String actualQuality = quality; // Default to requested quality
|
String actualQuality = quality; // Default to requested quality
|
||||||
@@ -1689,7 +1564,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.i('Actual quality: $actualQuality');
|
_log.i('Actual quality: $actualQuality');
|
||||||
}
|
}
|
||||||
|
|
||||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||||
_log.d(
|
_log.d(
|
||||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||||
@@ -1719,11 +1593,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
filePath = flacPath;
|
filePath = flacPath;
|
||||||
_log.d('Converted to FLAC: $flacPath');
|
_log.d('Converted to FLAC: $flacPath');
|
||||||
|
|
||||||
// After conversion, embed metadata and cover to the new FLAC file
|
|
||||||
_log.d('Embedding metadata and cover to converted FLAC...');
|
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||||
try {
|
try {
|
||||||
// Update track with actual metadata from backend result (if available)
|
|
||||||
// This creates the most accurate metadata possible (from the service itself)
|
|
||||||
Track finalTrack = trackToDownload;
|
Track finalTrack = trackToDownload;
|
||||||
if (result.containsKey('track_number') ||
|
if (result.containsKey('track_number') ||
|
||||||
result.containsKey('release_date')) {
|
result.containsKey('release_date')) {
|
||||||
@@ -1739,7 +1610,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear',
|
'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create updated track object with safety check for 0/null
|
|
||||||
final newTrackNumber =
|
final newTrackNumber =
|
||||||
(backendTrackNum != null && backendTrackNum > 0)
|
(backendTrackNum != null && backendTrackNum > 0)
|
||||||
? backendTrackNum
|
? backendTrackNum
|
||||||
@@ -1772,7 +1642,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use enriched/updated track for metadata embedding
|
|
||||||
await _embedMetadataAndCover(flacPath, finalTrack);
|
await _embedMetadataAndCover(flacPath, finalTrack);
|
||||||
_log.d('Metadata and cover embedded successfully');
|
_log.d('Metadata and cover embedded successfully');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1785,18 +1654,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||||
// Keep the M4A file if conversion fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check again if cancelled before updating status and adding to history
|
|
||||||
final itemAfterDownload = state.items.firstWhere(
|
final itemAfterDownload = state.items.firstWhere(
|
||||||
(i) => i.id == item.id,
|
(i) => i.id == item.id,
|
||||||
orElse: () => item,
|
orElse: () => item,
|
||||||
);
|
);
|
||||||
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
||||||
_log.i('Download was cancelled during finalization, cleaning up');
|
_log.i('Download was cancelled during finalization, cleaning up');
|
||||||
// Delete the downloaded file
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
try {
|
try {
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
@@ -1818,15 +1684,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run post-processing hooks if enabled
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
await _runPostProcessingHooks(filePath, trackToDownload);
|
await _runPostProcessingHooks(filePath, trackToDownload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment completed counter
|
|
||||||
_completedInSession++;
|
_completedInSession++;
|
||||||
|
|
||||||
// Show completion notification for this track
|
|
||||||
await _notificationService.showDownloadComplete(
|
await _notificationService.showDownloadComplete(
|
||||||
trackName: item.track.name,
|
trackName: item.track.name,
|
||||||
artistName: item.track.artistName,
|
artistName: item.track.artistName,
|
||||||
@@ -1835,7 +1698,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
// Extract metadata from backend result (most accurate source)
|
|
||||||
final backendTitle = result['title'] as String?;
|
final backendTitle = result['title'] as String?;
|
||||||
final backendArtist = result['artist'] as String?;
|
final backendArtist = result['artist'] as String?;
|
||||||
final backendAlbum = result['album'] as String?;
|
final backendAlbum = result['album'] as String?;
|
||||||
@@ -1846,7 +1708,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final backendSampleRate = result['actual_sample_rate'] as int?;
|
final backendSampleRate = result['actual_sample_rate'] as int?;
|
||||||
final backendISRC = result['isrc'] as String?;
|
final backendISRC = result['isrc'] as String?;
|
||||||
|
|
||||||
// Log cover URL for debugging
|
|
||||||
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||||
|
|
||||||
final historyAlbumArtist =
|
final historyAlbumArtist =
|
||||||
@@ -1894,7 +1755,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-remove completed item from queue (it's now in history)
|
|
||||||
removeItem(item.id);
|
removeItem(item.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1915,7 +1775,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert error type string to enum
|
|
||||||
DownloadErrorType errorType;
|
DownloadErrorType errorType;
|
||||||
switch (errorTypeStr) {
|
switch (errorTypeStr) {
|
||||||
case 'not_found':
|
case 'not_found':
|
||||||
@@ -1944,7 +1803,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_failedInSession++;
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment download counter and cleanup connections periodically
|
|
||||||
_downloadCount++;
|
_downloadCount++;
|
||||||
if (_downloadCount % _cleanupInterval == 0) {
|
if (_downloadCount % _cleanupInterval == 0) {
|
||||||
_log.d(
|
_log.d(
|
||||||
@@ -1971,7 +1829,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
String errorMsg = e.toString();
|
String errorMsg = e.toString();
|
||||||
DownloadErrorType errorType = DownloadErrorType.unknown;
|
DownloadErrorType errorType = DownloadErrorType.unknown;
|
||||||
|
|
||||||
// Check for specific Deezer fallback error
|
|
||||||
if (errorMsg.contains('could not find Deezer equivalent') ||
|
if (errorMsg.contains('could not find Deezer equivalent') ||
|
||||||
errorMsg.contains('track not found on Deezer')) {
|
errorMsg.contains('track not found on Deezer')) {
|
||||||
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
||||||
|
|||||||
@@ -175,12 +175,10 @@ class SearchBehavior {
|
|||||||
/// Get thumbnail size based on configuration
|
/// Get thumbnail size based on configuration
|
||||||
/// Returns (width, height) tuple
|
/// Returns (width, height) tuple
|
||||||
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
||||||
// If custom dimensions specified, use them
|
|
||||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise use ratio presets
|
|
||||||
switch (thumbnailRatio) {
|
switch (thumbnailRatio) {
|
||||||
case 'wide': // 16:9 - YouTube style
|
case 'wide': // 16:9 - YouTube style
|
||||||
return (defaultSize * 16 / 9, defaultSize);
|
return (defaultSize * 16 / 9, defaultSize);
|
||||||
@@ -558,10 +556,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||||
_log.d('Set extension $extensionId enabled: $enabled');
|
_log.d('Set extension $extensionId enabled: $enabled');
|
||||||
|
|
||||||
// Get extension info before updating state
|
|
||||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||||
|
|
||||||
// Update local state
|
|
||||||
final extensions = state.extensions.map((e) {
|
final extensions = state.extensions.map((e) {
|
||||||
if (e.id == extensionId) {
|
if (e.id == extensionId) {
|
||||||
return e.copyWith(enabled: enabled);
|
return e.copyWith(enabled: enabled);
|
||||||
@@ -571,18 +567,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
|
|
||||||
state = state.copyWith(extensions: extensions);
|
state = state.copyWith(extensions: extensions);
|
||||||
|
|
||||||
// If disabling an extension, reset related settings
|
|
||||||
if (!enabled && ext != null) {
|
if (!enabled && ext != null) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
// If this extension was the search provider, clear it and reset to Deezer
|
|
||||||
if (settings.searchProvider == extensionId) {
|
if (settings.searchProvider == extensionId) {
|
||||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||||
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this extension was the default download service, reset to Tidal
|
|
||||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
.toList();
|
.toList();
|
||||||
state = state.copyWith(items: items, isLoaded: true);
|
state = state.copyWith(items: items, isLoaded: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Invalid JSON, start fresh
|
|
||||||
state = state.copyWith(isLoaded: true);
|
state = state.copyWith(isLoaded: true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -201,19 +200,15 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _recordAccess(RecentAccessItem item) {
|
void _recordAccess(RecentAccessItem item) {
|
||||||
// Debug log
|
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
|
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
|
||||||
|
|
||||||
// Remove any existing entry with same unique key
|
|
||||||
final updatedItems = state.items
|
final updatedItems = state.items
|
||||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Add new item at the beginning
|
|
||||||
updatedItems.insert(0, item);
|
updatedItems.insert(0, item);
|
||||||
|
|
||||||
// Limit to max items
|
|
||||||
if (updatedItems.length > _maxRecentItems) {
|
if (updatedItems.length > _maxRecentItems) {
|
||||||
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
|
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
|
||||||
}
|
}
|
||||||
@@ -221,7 +216,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
state = state.copyWith(items: updatedItems);
|
state = state.copyWith(items: updatedItems);
|
||||||
_saveHistory();
|
_saveHistory();
|
||||||
|
|
||||||
// Debug log
|
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[RecentAccess] Total items now: ${updatedItems.length}');
|
print('[RecentAccess] Total items now: ${updatedItems.length}');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
|
||||||
// Run migrations if needed
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
|
|
||||||
// Apply Spotify credentials to Go backend on load
|
|
||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
|
|
||||||
// Sync logging state
|
|
||||||
LogBuffer.loggingEnabled = state.enableLogging;
|
LogBuffer.loggingEnabled = state.enableLogging;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,16 +35,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||||
|
|
||||||
if (lastMigration < 1) {
|
if (lastMigration < 1) {
|
||||||
// Migration 1: Set metadataSource to 'deezer' for existing users
|
|
||||||
// Only apply if user hasn't enabled custom Spotify credentials
|
|
||||||
// (users with custom credentials likely prefer Spotify)
|
|
||||||
if (!state.useCustomSpotifyCredentials) {
|
if (!state.useCustomSpotifyCredentials) {
|
||||||
state = state.copyWith(metadataSource: 'deezer');
|
state = state.copyWith(metadataSource: 'deezer');
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current migration version
|
|
||||||
if (lastMigration < _currentMigrationVersion) {
|
if (lastMigration < _currentMigrationVersion) {
|
||||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
}
|
}
|
||||||
@@ -60,7 +53,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
|
|
||||||
/// Apply current Spotify credentials to Go backend
|
/// Apply current Spotify credentials to Go backend
|
||||||
Future<void> _applySpotifyCredentials() async {
|
Future<void> _applySpotifyCredentials() async {
|
||||||
// Only apply if both fields are set
|
|
||||||
if (state.spotifyClientId.isNotEmpty &&
|
if (state.spotifyClientId.isNotEmpty &&
|
||||||
state.spotifyClientSecret.isNotEmpty) {
|
state.spotifyClientSecret.isNotEmpty) {
|
||||||
await PlatformBridge.setSpotifyCredentials(
|
await PlatformBridge.setSpotifyCredentials(
|
||||||
@@ -68,8 +60,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state.spotifyClientSecret,
|
state.spotifyClientSecret,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Note: If credentials are empty, Spotify API will return error
|
|
||||||
// User should use Deezer as metadata source instead
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDefaultService(String service) {
|
void setDefaultService(String service) {
|
||||||
@@ -113,7 +103,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setConcurrentDownloads(int count) {
|
void setConcurrentDownloads(int count) {
|
||||||
// Clamp between 1 and 3
|
|
||||||
final clamped = count.clamp(1, 3);
|
final clamped = count.clamp(1, 3);
|
||||||
state = state.copyWith(concurrentDownloads: clamped);
|
state = state.copyWith(concurrentDownloads: clamped);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -207,7 +196,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
void setEnableLogging(bool enabled) {
|
void setEnableLogging(bool enabled) {
|
||||||
state = state.copyWith(enableLogging: enabled);
|
state = state.copyWith(enableLogging: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
// Sync logging state to LogBuffer
|
|
||||||
LogBuffer.loggingEnabled = enabled;
|
LogBuffer.loggingEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,14 +142,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve hasSearchText during fetch
|
|
||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, check if any extension can handle this URL
|
|
||||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||||
if (extensionHandler != null) {
|
if (extensionHandler != null) {
|
||||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||||
@@ -188,7 +185,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
// Parse top tracks if available
|
|
||||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||||
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||||
|
|
||||||
@@ -209,13 +205,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No extension handler found, try Spotify URL parsing
|
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
// Use the new fallback-enabled method
|
|
||||||
Map<String, dynamic> metadata;
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -225,7 +219,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[FetchURL] Metadata fetch success');
|
print('[FetchURL] Metadata fetch success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If fallback also fails, show error
|
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[FetchURL] Metadata fetch failed: $e');
|
print('[FetchURL] Metadata fetch failed: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -252,7 +245,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
coverUrl: albumInfo['images'] as String?,
|
coverUrl: albumInfo['images'] as String?,
|
||||||
);
|
);
|
||||||
// Pre-warm cache for album tracks in background
|
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
@@ -265,7 +257,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
playlistName: owner?['name'] as String?,
|
playlistName: owner?['name'] as String?,
|
||||||
coverUrl: owner?['images'] as String?,
|
coverUrl: owner?['images'] as String?,
|
||||||
);
|
);
|
||||||
// Pre-warm cache for playlist tracks in background
|
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
@@ -281,21 +272,17 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return;
|
||||||
// Preserve hasSearchText on error so user stays on search screen
|
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query, {String? metadataSource}) async {
|
Future<void> search(String query, {String? metadataSource}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve hasSearchText during search
|
|
||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if extension providers should be used for search
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final extensionState = ref.read(extensionProvider);
|
final extensionState = ref.read(extensionProvider);
|
||||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||||
@@ -308,7 +295,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
searchProvider != null &&
|
searchProvider != null &&
|
||||||
searchProvider.isNotEmpty;
|
searchProvider.isNotEmpty;
|
||||||
|
|
||||||
// Use Deezer or Spotify based on settings
|
|
||||||
final source = metadataSource ?? 'deezer';
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
@@ -318,14 +304,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
List<Track> extensionTracks = [];
|
List<Track> extensionTracks = [];
|
||||||
|
|
||||||
// Try extension providers first if enabled
|
|
||||||
if (useExtensions) {
|
if (useExtensions) {
|
||||||
try {
|
try {
|
||||||
_log.d('Calling extension search API...');
|
_log.d('Calling extension search API...');
|
||||||
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
||||||
_log.i('Extensions returned ${extResults.length} tracks');
|
_log.i('Extensions returned ${extResults.length} tracks');
|
||||||
|
|
||||||
// Parse extension results
|
|
||||||
for (final t in extResults) {
|
for (final t in extResults) {
|
||||||
try {
|
try {
|
||||||
extensionTracks.add(_parseSearchTrack(t));
|
extensionTracks.add(_parseSearchTrack(t));
|
||||||
@@ -338,7 +322,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also search with built-in providers
|
|
||||||
if (source == 'deezer') {
|
if (source == 'deezer') {
|
||||||
_log.d('Calling Deezer search API...');
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
@@ -359,13 +342,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||||
|
|
||||||
// Parse tracks with error handling per item
|
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
|
|
||||||
// Add extension tracks first (they have priority)
|
|
||||||
tracks.addAll(extensionTracks);
|
tracks.addAll(extensionTracks);
|
||||||
|
|
||||||
// Add built-in provider tracks, avoiding duplicates by ISRC
|
|
||||||
final existingIsrcs = extensionTracks
|
final existingIsrcs = extensionTracks
|
||||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||||
.map((t) => t.isrc!)
|
.map((t) => t.isrc!)
|
||||||
@@ -376,7 +356,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
try {
|
try {
|
||||||
if (t is Map<String, dynamic>) {
|
if (t is Map<String, dynamic>) {
|
||||||
final track = _parseSearchTrack(t);
|
final track = _parseSearchTrack(t);
|
||||||
// Skip if we already have this track from extensions
|
|
||||||
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
|
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -389,7 +368,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse artists with error handling per item
|
|
||||||
final artists = <SearchArtist>[];
|
final artists = <SearchArtist>[];
|
||||||
for (int i = 0; i < artistList.length; i++) {
|
for (int i = 0; i < artistList.length; i++) {
|
||||||
final a = artistList[i];
|
final a = artistList[i];
|
||||||
@@ -421,10 +399,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
/// Perform custom search using a specific extension
|
/// Perform custom search using a specific extension
|
||||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve hasSearchText during search
|
|
||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -439,7 +415,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
_log.i('Custom search returned ${results.length} tracks');
|
_log.i('Custom search returned ${results.length} tracks');
|
||||||
|
|
||||||
// Parse tracks with error handling per item, setting source to extension ID
|
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
for (int i = 0; i < results.length; i++) {
|
for (int i = 0; i < results.length; i++) {
|
||||||
final t = results[i];
|
final t = results[i];
|
||||||
@@ -502,7 +477,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
tracks[index] = updatedTrack;
|
tracks[index] = updatedTrack;
|
||||||
state = state.copyWith(tracks: tracks);
|
state = state.copyWith(tracks: tracks);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently fail availability check
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +528,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||||
// Handle duration_ms which might be int or double
|
|
||||||
int durationMs = 0;
|
int durationMs = 0;
|
||||||
final durationValue = data['duration_ms'];
|
final durationValue = data['duration_ms'];
|
||||||
if (durationValue is int) {
|
if (durationValue is int) {
|
||||||
@@ -563,7 +536,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
durationMs = durationValue.toInt();
|
durationMs = durationValue.toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get item_type - can be 'track', 'album', or 'playlist'
|
|
||||||
final itemType = data['item_type']?.toString();
|
final itemType = data['item_type']?.toString();
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
@@ -610,23 +582,18 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
/// Pre-warm track ID cache for faster downloads
|
/// Pre-warm track ID cache for faster downloads
|
||||||
/// Runs in background, doesn't block UI
|
/// Runs in background, doesn't block UI
|
||||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||||
// Only pre-warm if we have tracks with ISRC
|
|
||||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||||
if (tracksWithIsrc.isEmpty) return;
|
if (tracksWithIsrc.isEmpty) return;
|
||||||
|
|
||||||
// Build request list for Go backend
|
|
||||||
final cacheRequests = tracksWithIsrc.map((t) => {
|
final cacheRequests = tracksWithIsrc.map((t) => {
|
||||||
'isrc': t.isrc!,
|
'isrc': t.isrc!,
|
||||||
'track_name': t.name,
|
'track_name': t.name,
|
||||||
'artist_name': t.artistName,
|
'artist_name': t.artistName,
|
||||||
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
||||||
'service': 'tidal', // Default to tidal for pre-warming
|
'service': 'tidal',
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Fire and forget - runs in background
|
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||||
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
|
|
||||||
// Silently ignore errors - this is just an optimization
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Record access for recent history
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
|
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
|
||||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||||
@@ -77,7 +76,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Priority: widget.tracks > cache > fetch
|
|
||||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
if (_tracks == null) {
|
if (_tracks == null) {
|
||||||
_fetchTracks();
|
_fetchTracks();
|
||||||
@@ -89,14 +87,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
try {
|
try {
|
||||||
Map<String, dynamic> metadata;
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
// Check if this is a Deezer album ID (format: "deezer:123456")
|
|
||||||
if (widget.albumId.startsWith('deezer:')) {
|
if (widget.albumId.startsWith('deezer:')) {
|
||||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
||||||
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
||||||
} else {
|
} else {
|
||||||
// Spotify album - use fallback method
|
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
@@ -106,7 +102,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
_AlbumCache.set(widget.albumId, tracks);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -413,7 +408,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default error display
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
@@ -443,12 +437,10 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Only watch the specific item for this track
|
|
||||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Check if track is in history (already downloaded before)
|
|
||||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
return state.isDownloaded(track.id);
|
return state.isDownloaded(track.id);
|
||||||
}));
|
}));
|
||||||
@@ -459,7 +451,6 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
final progress = queueItem?.progress ?? 0.0;
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
// Show as downloaded if in queue completed OR in history
|
|
||||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Record access for recent history
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final providerId = widget.extensionId ??
|
final providerId = widget.extensionId ??
|
||||||
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||||
@@ -112,18 +111,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// If this is an extension artist, use provided data only - don't fetch from Spotify/Deezer
|
|
||||||
if (widget.extensionId != null) {
|
if (widget.extensionId != null) {
|
||||||
_albums = widget.albums;
|
_albums = widget.albums;
|
||||||
_topTracks = widget.topTracks;
|
_topTracks = widget.topTracks;
|
||||||
_headerImageUrl = widget.headerImageUrl;
|
_headerImageUrl = widget.headerImageUrl;
|
||||||
_monthlyListeners = widget.monthlyListeners;
|
_monthlyListeners = widget.monthlyListeners;
|
||||||
// Extension artists don't need additional fetching
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority: widget data > cache > fetch
|
|
||||||
// But always fetch if topTracks is missing (to get popular tracks)
|
|
||||||
final cached = _ArtistCache.get(widget.artistId);
|
final cached = _ArtistCache.get(widget.artistId);
|
||||||
|
|
||||||
if (widget.albums != null) {
|
if (widget.albums != null) {
|
||||||
@@ -132,7 +127,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
_headerImageUrl = widget.headerImageUrl;
|
_headerImageUrl = widget.headerImageUrl;
|
||||||
_monthlyListeners = widget.monthlyListeners;
|
_monthlyListeners = widget.monthlyListeners;
|
||||||
|
|
||||||
// If we have albums but no top tracks, fetch to get them
|
|
||||||
if (_topTracks == null || _topTracks!.isEmpty) {
|
if (_topTracks == null || _topTracks!.isEmpty) {
|
||||||
_fetchDiscography();
|
_fetchDiscography();
|
||||||
}
|
}
|
||||||
@@ -142,7 +136,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
_headerImageUrl = cached.headerImageUrl;
|
_headerImageUrl = cached.headerImageUrl;
|
||||||
_monthlyListeners = cached.monthlyListeners;
|
_monthlyListeners = cached.monthlyListeners;
|
||||||
|
|
||||||
// If cache has no top tracks, fetch
|
|
||||||
if (_topTracks == null || _topTracks!.isEmpty) {
|
if (_topTracks == null || _topTracks!.isEmpty) {
|
||||||
_fetchDiscography();
|
_fetchDiscography();
|
||||||
}
|
}
|
||||||
@@ -159,14 +152,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
String? headerImage;
|
String? headerImage;
|
||||||
int? listeners;
|
int? listeners;
|
||||||
|
|
||||||
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
|
||||||
if (widget.artistId.startsWith('deezer:')) {
|
if (widget.artistId.startsWith('deezer:')) {
|
||||||
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
} else {
|
} else {
|
||||||
// Spotify artist - use extension handler via URL
|
|
||||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
|
|
||||||
@@ -175,7 +166,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
// Parse top tracks if available
|
|
||||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||||
if (topTracksList.isNotEmpty) {
|
if (topTracksList.isNotEmpty) {
|
||||||
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
@@ -184,14 +174,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
headerImage = artistData['header_image'] as String?;
|
headerImage = artistData['header_image'] as String?;
|
||||||
listeners = artistData['listeners'] as int?;
|
listeners = artistData['listeners'] as int?;
|
||||||
} else {
|
} else {
|
||||||
// Fallback to Spotify API metadata
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache (preserve existing values if new ones are null)
|
|
||||||
final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
|
final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
|
||||||
final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners;
|
final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners;
|
||||||
|
|
||||||
@@ -283,10 +271,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
child: _buildErrorWidget(_error!, colorScheme),
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
)),
|
)),
|
||||||
if (!_isLoadingDiscography && _error == null) ...[
|
if (!_isLoadingDiscography && _error == null) ...[
|
||||||
// Popular tracks section
|
|
||||||
if (_topTracks != null && _topTracks!.isNotEmpty)
|
if (_topTracks != null && _topTracks!.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
|
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
|
||||||
// Discography sections
|
|
||||||
if (albumsOnly.isNotEmpty)
|
if (albumsOnly.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
|
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
|
||||||
if (singles.isNotEmpty)
|
if (singles.isNotEmpty)
|
||||||
@@ -302,8 +288,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
|
|
||||||
/// Build Spotify-style header with full-width image and artist name overlay
|
/// Build Spotify-style header with full-width image and artist name overlay
|
||||||
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
// Use header image if available, otherwise fall back to cover URL
|
|
||||||
// Prefer: fetched header > widget header > widget cover
|
|
||||||
String? imageUrl = _headerImageUrl;
|
String? imageUrl = _headerImageUrl;
|
||||||
if (imageUrl == null || imageUrl.isEmpty) {
|
if (imageUrl == null || imageUrl.isEmpty) {
|
||||||
imageUrl = widget.headerImageUrl;
|
imageUrl = widget.headerImageUrl;
|
||||||
@@ -316,7 +300,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
imageUrl.isNotEmpty &&
|
imageUrl.isNotEmpty &&
|
||||||
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
||||||
|
|
||||||
// Format monthly listeners
|
|
||||||
String? listenersText;
|
String? listenersText;
|
||||||
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
||||||
if (listeners != null && listeners > 0) {
|
if (listeners != null && listeners > 0) {
|
||||||
@@ -334,7 +317,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Background image - full width, no circular crop
|
|
||||||
if (hasValidImage)
|
if (hasValidImage)
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
@@ -354,7 +336,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
|
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
// Gradient overlay for text readability
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -370,7 +351,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Artist name and listeners at bottom
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
@@ -436,7 +416,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Widget _buildPopularSection(ColorScheme colorScheme) {
|
Widget _buildPopularSection(ColorScheme colorScheme) {
|
||||||
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
|
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
// Show max 5 tracks
|
|
||||||
final tracks = _topTracks!.take(5).toList();
|
final tracks = _topTracks!.take(5).toList();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -462,12 +441,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
|
|
||||||
/// Build a single popular track item with dynamic download status
|
/// Build a single popular track item with dynamic download status
|
||||||
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
|
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
|
||||||
// Watch download queue for this track's status
|
|
||||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Check if track is in history (already downloaded before)
|
|
||||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
return state.isDownloaded(track.id);
|
return state.isDownloaded(track.id);
|
||||||
}));
|
}));
|
||||||
@@ -478,7 +455,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
final progress = queueItem?.progress ?? 0.0;
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
// Show as downloaded if in queue completed OR in history
|
|
||||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
@@ -487,7 +463,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Rank number
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -499,7 +474,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Album art
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: track.coverUrl != null
|
child: track.coverUrl != null
|
||||||
@@ -529,7 +503,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Track info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -554,7 +527,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Download button with status
|
|
||||||
_buildPopularDownloadButton(
|
_buildPopularDownloadButton(
|
||||||
track: track,
|
track: track,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
@@ -738,7 +710,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Album cover
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: album.coverUrl != null
|
child: album.coverUrl != null
|
||||||
@@ -768,7 +739,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Album name
|
|
||||||
Text(
|
Text(
|
||||||
album.name,
|
album.name,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
@@ -778,7 +748,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
// Year and track count
|
|
||||||
Text(
|
Text(
|
||||||
album.totalTracks > 0
|
album.totalTracks > 0
|
||||||
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
|
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
// Multi-select state
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
@@ -159,11 +158,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
// Watch history and get tracks for this album (reactive!)
|
|
||||||
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
final tracks = _getAlbumTracks(allHistoryItems);
|
final tracks = _getAlbumTracks(allHistoryItems);
|
||||||
|
|
||||||
// Auto-pop if album has less than 2 tracks (no longer an "album")
|
|
||||||
if (tracks.length < 2) {
|
if (tracks.length < 2) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
@@ -171,7 +168,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up selected IDs that no longer exist
|
|
||||||
final validIds = tracks.map((t) => t.id).toSet();
|
final validIds = tracks.map((t) => t.id).toSet();
|
||||||
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||||
if (_selectedIds.isEmpty && _isSelectionMode) {
|
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||||
@@ -200,7 +196,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom Selection Action Bar
|
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case 0:
|
case 0:
|
||||||
// Already on home
|
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
context.push('/queue');
|
context.push('/queue');
|
||||||
@@ -112,7 +111,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
body: Column(
|
body: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// URL Input
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -132,7 +130,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error message
|
|
||||||
if (trackState.error != null)
|
if (trackState.error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
@@ -142,15 +139,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Loading indicator
|
|
||||||
if (trackState.isLoading)
|
if (trackState.isLoading)
|
||||||
LinearProgressIndicator(color: colorScheme.primary),
|
LinearProgressIndicator(color: colorScheme.primary),
|
||||||
|
|
||||||
// Album/Playlist header
|
|
||||||
if (trackState.albumName != null || trackState.playlistName != null)
|
if (trackState.albumName != null || trackState.playlistName != null)
|
||||||
_buildHeader(trackState, colorScheme),
|
_buildHeader(trackState, colorScheme),
|
||||||
|
|
||||||
// Download All button
|
|
||||||
if (trackState.tracks.length > 1)
|
if (trackState.tracks.length > 1)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
@@ -164,7 +158,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Track list
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: trackState.tracks.isEmpty
|
child: trackState.tracks.isEmpty
|
||||||
? _buildEmptyState(colorScheme)
|
? _buildEmptyState(colorScheme)
|
||||||
@@ -252,7 +245,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Play all button
|
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: _downloadAll,
|
onPressed: _downloadAll,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@@ -271,7 +263,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
final track = ref.watch(trackProvider).tracks[index];
|
final track = ref.watch(trackProvider).tracks[index];
|
||||||
final isCollection = track.isCollection;
|
final isCollection = track.isCollection;
|
||||||
|
|
||||||
// Determine subtitle text based on item type
|
|
||||||
String subtitleText;
|
String subtitleText;
|
||||||
if (isCollection) {
|
if (isCollection) {
|
||||||
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
|
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
|
||||||
@@ -329,11 +320,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openCollection(Track track) async {
|
Future<void> _openCollection(Track track) async {
|
||||||
// Get the extension ID from the track source
|
|
||||||
final extensionId = track.source;
|
final extensionId = track.source;
|
||||||
if (extensionId == null) return;
|
if (extensionId == null) return;
|
||||||
|
|
||||||
// Fetch album/playlist tracks using the extension
|
|
||||||
try {
|
try {
|
||||||
if (track.isAlbumItem) {
|
if (track.isAlbumItem) {
|
||||||
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
|
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
|
||||||
|
|||||||
+90
-118
@@ -30,7 +30,22 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final _urlController = TextEditingController();
|
final _urlController = TextEditingController();
|
||||||
bool _isTyping = false;
|
bool _isTyping = false;
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
String? _lastSearchQuery; // Track last searched query to avoid duplicate searches
|
String? _lastSearchQuery;
|
||||||
|
|
||||||
|
/// Debounce timer for live search (extension-only feature)
|
||||||
|
Timer? _liveSearchDebounce;
|
||||||
|
|
||||||
|
/// Flag to prevent concurrent live search calls (prevents race conditions in extensions)
|
||||||
|
bool _isLiveSearchInProgress = false;
|
||||||
|
|
||||||
|
/// Pending query to execute after current search completes
|
||||||
|
String? _pendingLiveSearchQuery;
|
||||||
|
|
||||||
|
/// Minimum characters required to trigger live search
|
||||||
|
static const int _minLiveSearchChars = 3;
|
||||||
|
|
||||||
|
/// Debounce duration for live search
|
||||||
|
static const Duration _liveSearchDelay = Duration(milliseconds: 800);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
@@ -44,6 +59,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_liveSearchDebounce?.cancel();
|
||||||
_urlController.removeListener(_onSearchChanged);
|
_urlController.removeListener(_onSearchChanged);
|
||||||
_searchFocusNode.removeListener(_onSearchFocusChanged);
|
_searchFocusNode.removeListener(_onSearchFocusChanged);
|
||||||
_urlController.dispose();
|
_urlController.dispose();
|
||||||
@@ -52,9 +68,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onSearchFocusChanged() {
|
void _onSearchFocusChanged() {
|
||||||
// When focused, enter recent access mode
|
|
||||||
// When unfocused (keyboard dismissed), keep recent access mode visible
|
|
||||||
// User must press back button to exit recent access mode
|
|
||||||
if (_searchFocusNode.hasFocus) {
|
if (_searchFocusNode.hasFocus) {
|
||||||
ref.read(trackProvider.notifier).setShowingRecentAccess(true);
|
ref.read(trackProvider.notifier).setShowingRecentAccess(true);
|
||||||
}
|
}
|
||||||
@@ -62,8 +75,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
/// Called when trackState changes - used to sync search bar with state
|
/// Called when trackState changes - used to sync search bar with state
|
||||||
void _onTrackStateChanged(TrackState? previous, TrackState next) {
|
void _onTrackStateChanged(TrackState? previous, TrackState next) {
|
||||||
// If state was cleared (no content, no search text, not loading), clear the search bar
|
|
||||||
// BUT only if search field is not focused (to prevent clearing while user is typing)
|
|
||||||
if (previous != null &&
|
if (previous != null &&
|
||||||
!next.hasContent &&
|
!next.hasContent &&
|
||||||
!next.hasSearchText &&
|
!next.hasSearchText &&
|
||||||
@@ -73,24 +84,84 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
setState(() => _isTyping = false);
|
setState(() => _isTyping = false);
|
||||||
}
|
}
|
||||||
} void _onSearchChanged() {
|
}
|
||||||
|
|
||||||
|
/// Check if live search is available (extension is set as search provider)
|
||||||
|
bool _isLiveSearchEnabled() {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
final extState = ref.read(extensionProvider);
|
||||||
|
final searchProvider = settings.searchProvider;
|
||||||
|
|
||||||
|
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||||
|
|
||||||
|
// Check if the extension is enabled and has search capability
|
||||||
|
final extension = extState.extensions.where((e) => e.id == searchProvider && e.enabled).firstOrNull;
|
||||||
|
return extension != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged() {
|
||||||
final text = _urlController.text.trim();
|
final text = _urlController.text.trim();
|
||||||
|
|
||||||
// Update search text state for MainShell back button handling
|
|
||||||
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
||||||
|
|
||||||
// Update typing state immediately for UI transition
|
|
||||||
if (text.isNotEmpty && !_isTyping) {
|
if (text.isNotEmpty && !_isTyping) {
|
||||||
setState(() => _isTyping = true);
|
setState(() => _isTyping = true);
|
||||||
} else if (text.isEmpty && _isTyping) {
|
} else if (text.isEmpty && _isTyping) {
|
||||||
setState(() => _isTyping = false);
|
setState(() => _isTyping = false);
|
||||||
|
_liveSearchDebounce?.cancel();
|
||||||
// Don't clear provider here - it causes focus issues
|
// Don't clear provider here - it causes focus issues
|
||||||
// Provider will be cleared when user explicitly clears or navigates away
|
// Provider will be cleared when user explicitly clears or navigates away
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No auto-search - user must press Enter to search
|
// Live search - only for extensions
|
||||||
// This saves API calls and avoids rate limiting
|
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
|
||||||
|
// Skip if it's a URL (let user press enter for URLs)
|
||||||
|
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
||||||
|
|
||||||
|
_liveSearchDebounce?.cancel();
|
||||||
|
_liveSearchDebounce = Timer(_liveSearchDelay, () {
|
||||||
|
if (mounted && _urlController.text.trim() == text) {
|
||||||
|
_executeLiveSearch(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute live search with concurrency protection
|
||||||
|
/// Prevents race conditions in extensions by ensuring only one search runs at a time
|
||||||
|
Future<void> _executeLiveSearch(String query) async {
|
||||||
|
// If a search is already in progress, queue this one
|
||||||
|
if (_isLiveSearchInProgress) {
|
||||||
|
_pendingLiveSearchQuery = query;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLiveSearchInProgress = true;
|
||||||
|
_pendingLiveSearchQuery = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _performSearch(query);
|
||||||
|
} finally {
|
||||||
|
_isLiveSearchInProgress = false;
|
||||||
|
|
||||||
|
// Check if there's a pending query that was queued while we were searching
|
||||||
|
final pending = _pendingLiveSearchQuery;
|
||||||
|
_pendingLiveSearchQuery = null;
|
||||||
|
|
||||||
|
// Execute pending query if it's different from what we just searched
|
||||||
|
// and still matches current text field content
|
||||||
|
if (pending != null &&
|
||||||
|
pending != query &&
|
||||||
|
mounted &&
|
||||||
|
_urlController.text.trim() == pending) {
|
||||||
|
// Small delay to let extension's state settle
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
if (mounted && _urlController.text.trim() == pending) {
|
||||||
|
_executeLiveSearch(pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performSearch(String query) async {
|
Future<void> _performSearch(String query) async {
|
||||||
@@ -98,22 +169,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
final searchProvider = settings.searchProvider;
|
final searchProvider = settings.searchProvider;
|
||||||
|
|
||||||
// Skip if same query already searched with same provider
|
|
||||||
final searchKey = '${searchProvider ?? 'default'}:$query';
|
final searchKey = '${searchProvider ?? 'default'}:$query';
|
||||||
if (_lastSearchQuery == searchKey) return;
|
if (_lastSearchQuery == searchKey) return;
|
||||||
_lastSearchQuery = searchKey;
|
_lastSearchQuery = searchKey;
|
||||||
|
|
||||||
// Check if extension search provider is set AND still enabled
|
|
||||||
final isExtensionEnabled = searchProvider != null &&
|
final isExtensionEnabled = searchProvider != null &&
|
||||||
searchProvider.isNotEmpty &&
|
searchProvider.isNotEmpty &&
|
||||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||||
|
|
||||||
if (isExtensionEnabled) {
|
if (isExtensionEnabled) {
|
||||||
// Use custom search from extension
|
|
||||||
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
|
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
|
||||||
} else {
|
} else {
|
||||||
// Use default search (Deezer/Spotify)
|
|
||||||
// Also clear searchProvider if it was set but extension is disabled
|
|
||||||
if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) {
|
if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) {
|
||||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
}
|
}
|
||||||
@@ -126,7 +192,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
if (data?.text != null) {
|
if (data?.text != null) {
|
||||||
_urlController.text = data!.text!;
|
_urlController.text = data!.text!;
|
||||||
// For URLs, trigger fetch immediately after paste
|
|
||||||
final text = data.text!.trim();
|
final text = data.text!.trim();
|
||||||
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
||||||
_fetchMetadata();
|
_fetchMetadata();
|
||||||
@@ -135,9 +200,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clearAndRefresh() async {
|
Future<void> _clearAndRefresh() async {
|
||||||
|
_liveSearchDebounce?.cancel();
|
||||||
|
_pendingLiveSearchQuery = null;
|
||||||
_urlController.clear();
|
_urlController.clear();
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
_lastSearchQuery = null; // Reset last query
|
_lastSearchQuery = null;
|
||||||
setState(() => _isTyping = false);
|
setState(() => _isTyping = false);
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
}
|
}
|
||||||
@@ -159,7 +226,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
void _navigateToDetailIfNeeded() {
|
void _navigateToDetailIfNeeded() {
|
||||||
final trackState = ref.read(trackProvider);
|
final trackState = ref.read(trackProvider);
|
||||||
|
|
||||||
// Navigate to Album screen (recording is done in AlbumScreen.initState)
|
|
||||||
if (trackState.albumId != null && trackState.albumName != null && trackState.tracks.isNotEmpty) {
|
if (trackState.albumId != null && trackState.albumName != null && trackState.tracks.isNotEmpty) {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen(
|
Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumScreen(
|
||||||
albumId: trackState.albumId!,
|
albumId: trackState.albumId!,
|
||||||
@@ -173,9 +239,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to Playlist screen
|
|
||||||
if (trackState.playlistName != null && trackState.tracks.isNotEmpty) {
|
if (trackState.playlistName != null && trackState.tracks.isNotEmpty) {
|
||||||
// Record access for playlist (no separate screen to record in)
|
|
||||||
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
|
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
|
||||||
id: trackState.playlistName!,
|
id: trackState.playlistName!,
|
||||||
name: trackState.playlistName!,
|
name: trackState.playlistName!,
|
||||||
@@ -194,7 +258,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to Artist screen (recording is done in ArtistScreen.initState)
|
|
||||||
if (trackState.artistId != null && trackState.artistName != null && trackState.artistAlbums != null) {
|
if (trackState.artistId != null && trackState.artistName != null && trackState.artistAlbums != null) {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen(
|
Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistScreen(
|
||||||
artistId: trackState.artistId!,
|
artistId: trackState.artistId!,
|
||||||
@@ -234,11 +297,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
||||||
// Show loading dialog with progress
|
|
||||||
int currentProgress = 0;
|
int currentProgress = 0;
|
||||||
int totalTracks = 0;
|
int totalTracks = 0;
|
||||||
|
|
||||||
// Use StatefulBuilder to update dialog content
|
|
||||||
bool dialogShown = false;
|
bool dialogShown = false;
|
||||||
StateSetter? setDialogState;
|
StateSetter? setDialogState;
|
||||||
|
|
||||||
@@ -281,7 +342,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close progress dialog
|
|
||||||
if (dialogShown && mounted) {
|
if (dialogShown && mounted) {
|
||||||
Navigator.of(this.context).pop();
|
Navigator.of(this.context).pop();
|
||||||
}
|
}
|
||||||
@@ -294,7 +354,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
|
||||||
// Optionally show confirmation dialog
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: this.context,
|
context: this.context,
|
||||||
builder: (dialogCtx) => AlertDialog(
|
builder: (dialogCtx) => AlertDialog(
|
||||||
@@ -322,16 +381,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: l10n.snackbarViewQueue,
|
label: l10n.snackbarViewQueue,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Navigate to queue tab (handled by main_shell index)
|
|
||||||
// We don't have direct access to set index here easily without provider
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Only show error if pick was not cancelled (handled inside service logging usually, but maybe show snackbar if file empty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,41 +394,34 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
|
||||||
// Listen for state changes to sync search bar and auto-navigate
|
|
||||||
ref.listen<TrackState>(trackProvider, (previous, next) {
|
ref.listen<TrackState>(trackProvider, (previous, next) {
|
||||||
_onTrackStateChanged(previous, next);
|
_onTrackStateChanged(previous, next);
|
||||||
// Auto-navigate when URL fetch completes
|
|
||||||
if (previous != null && previous.isLoading && !next.isLoading && next.error == null) {
|
if (previous != null && previous.isLoading && !next.isLoading && next.error == null) {
|
||||||
_navigateToDetailIfNeeded();
|
_navigateToDetailIfNeeded();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use select() to only rebuild when specific fields change
|
|
||||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||||
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
|
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
|
||||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||||
|
|
||||||
// Watch extension state to update search hint when extensions load/change
|
|
||||||
ref.watch(extensionProvider.select((s) => s.isInitialized));
|
ref.watch(extensionProvider.select((s) => s.isInitialized));
|
||||||
ref.watch(extensionProvider.select((s) => s.extensions));
|
ref.watch(extensionProvider.select((s) => s.extensions));
|
||||||
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty);
|
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty);
|
||||||
final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
|
final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
|
||||||
// Move search bar up when in recent access mode or has results
|
|
||||||
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
|
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items));
|
final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items));
|
||||||
|
|
||||||
// Show recent access when in mode but no actual results yet (includes download history)
|
|
||||||
final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
|
final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
|
||||||
final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading;
|
final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading;
|
||||||
|
|
||||||
// Exit recent access mode when results appear
|
|
||||||
if (hasActualResults && isShowingRecentAccess) {
|
if (hasActualResults && isShowingRecentAccess) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||||
@@ -382,7 +430,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Unfocus search bar when tapping outside
|
|
||||||
if (_searchFocusNode.hasFocus) {
|
if (_searchFocusNode.hasFocus) {
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
}
|
}
|
||||||
@@ -392,7 +439,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar - always present
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -423,7 +469,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Idle content (logo, title) - always in tree, animated size
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
@@ -442,10 +487,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/logo-transparant.png',
|
'assets/images/logo-transparant.png',
|
||||||
color: colorScheme.onPrimary, // Tint with onPrimary color
|
color: colorScheme.onPrimary,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, _, _) => ClipRRect(
|
errorBuilder: (_, _, _) => ClipRRect(
|
||||||
// Fallback to original logo if transparent one is missing
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/logo.png',
|
'assets/images/logo.png',
|
||||||
@@ -476,7 +520,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Search bar - always present at same position in tree
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(16, hasResults ? 8 : 32, 16, hasResults ? 8 : 16),
|
padding: EdgeInsets.fromLTRB(16, hasResults ? 8 : 32, 16, hasResults ? 8 : 16),
|
||||||
@@ -484,14 +527,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Recent access history - shown when in recent access mode (persists after keyboard dismissed)
|
|
||||||
// User can exit by pressing back button
|
|
||||||
if (showRecentAccess)
|
if (showRecentAccess)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _buildRecentAccess(recentAccessItems, colorScheme),
|
child: _buildRecentAccess(recentAccessItems, colorScheme),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Idle content below search bar - always in tree
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
@@ -521,7 +561,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Results content - search results only (albums/artists/playlists navigate to separate screens)
|
|
||||||
..._buildSearchResults(
|
..._buildSearchResults(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
searchArtists: searchArtists,
|
searchArtists: searchArtists,
|
||||||
@@ -609,10 +648,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
/// Build recent access history section (shown when search focused)
|
/// Build recent access history section (shown when search focused)
|
||||||
Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) {
|
Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) {
|
||||||
// Merge with recent downloads to make the list more populated
|
|
||||||
final historyItems = ref.read(downloadHistoryProvider).items;
|
final historyItems = ref.read(downloadHistoryProvider).items;
|
||||||
|
|
||||||
// Convert download history to RecentAccessItem format
|
|
||||||
final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem(
|
final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem(
|
||||||
id: h.spotifyId!,
|
id: h.spotifyId!,
|
||||||
name: h.trackName,
|
name: h.trackName,
|
||||||
@@ -623,11 +660,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
providerId: 'download',
|
providerId: 'download',
|
||||||
)).toList();
|
)).toList();
|
||||||
|
|
||||||
// Merge and sort by accessedAt (most recent first)
|
|
||||||
final allItems = [...items, ...downloadItems];
|
final allItems = [...items, ...downloadItems];
|
||||||
allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
|
||||||
|
|
||||||
// Remove duplicates (keep the most recent one)
|
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
final uniqueItems = allItems.where((item) {
|
final uniqueItems = allItems.where((item) {
|
||||||
final key = '${item.type.name}:${item.id}';
|
final key = '${item.type.name}:${item.id}';
|
||||||
@@ -641,7 +676,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header with clear button
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -663,7 +697,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// List of recent items
|
|
||||||
...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)),
|
...uniqueItems.map((item) => _buildRecentAccessItem(item, colorScheme)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -671,7 +704,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) {
|
Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) {
|
||||||
// Icon and label based on type
|
|
||||||
IconData typeIcon;
|
IconData typeIcon;
|
||||||
String typeLabel;
|
String typeLabel;
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
@@ -698,7 +730,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Image
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(item.type == RecentAccessType.artist ? 28 : 4),
|
borderRadius: BorderRadius.circular(item.type == RecentAccessType.artist ? 28 : 4),
|
||||||
child: item.imageUrl != null && item.imageUrl!.isNotEmpty
|
child: item.imageUrl != null && item.imageUrl!.isNotEmpty
|
||||||
@@ -723,7 +754,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Text content
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -748,7 +778,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Delete button (like Spotify's X)
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant),
|
icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -767,7 +796,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case RecentAccessType.artist:
|
case RecentAccessType.artist:
|
||||||
// Check if artist is from extension (not spotify/deezer)
|
|
||||||
if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') {
|
if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') {
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ExtensionArtistScreen(
|
builder: (context) => ExtensionArtistScreen(
|
||||||
@@ -806,19 +834,15 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
case RecentAccessType.track:
|
case RecentAccessType.track:
|
||||||
// For tracks from download history, navigate to metadata screen
|
|
||||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(item.id);
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(item.id);
|
||||||
if (historyItem != null) {
|
if (historyItem != null) {
|
||||||
_navigateToMetadataScreen(historyItem);
|
_navigateToMetadataScreen(historyItem);
|
||||||
} else {
|
} else {
|
||||||
// Track not in history anymore
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(item.name)),
|
SnackBar(content: Text(item.name)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case RecentAccessType.playlist:
|
case RecentAccessType.playlist:
|
||||||
// Playlist needs tracks, so we just show info
|
|
||||||
// Could potentially re-fetch using URL handler if we stored URL
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))),
|
SnackBar(content: Text(context.l10n.recentPlaylistInfo(item.name))),
|
||||||
);
|
);
|
||||||
@@ -879,7 +903,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default error display
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
@@ -897,7 +920,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search results slivers - only shows search results (track list)
|
|
||||||
List<Widget> _buildSearchResults({
|
List<Widget> _buildSearchResults({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
required List<SearchArtist>? searchArtists,
|
required List<SearchArtist>? searchArtists,
|
||||||
@@ -910,29 +932,24 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate tracks from albums/playlists/artists
|
|
||||||
final realTracks = tracks.where((t) => !t.isCollection).toList();
|
final realTracks = tracks.where((t) => !t.isCollection).toList();
|
||||||
final albumItems = tracks.where((t) => t.isAlbumItem).toList();
|
final albumItems = tracks.where((t) => t.isAlbumItem).toList();
|
||||||
final playlistItems = tracks.where((t) => t.isPlaylistItem).toList();
|
final playlistItems = tracks.where((t) => t.isPlaylistItem).toList();
|
||||||
final artistItems = tracks.where((t) => t.isArtistItem).toList();
|
final artistItems = tracks.where((t) => t.isArtistItem).toList();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Error message - with special handling for rate limit (429)
|
|
||||||
if (error != null)
|
if (error != null)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: _buildErrorWidget(error, colorScheme),
|
child: _buildErrorWidget(error, colorScheme),
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Loading indicator
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
||||||
|
|
||||||
// Artist search results (horizontal scroll) - from built-in providers
|
|
||||||
if (searchArtists != null && searchArtists.isNotEmpty)
|
if (searchArtists != null && searchArtists.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)),
|
SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)),
|
||||||
|
|
||||||
// Artists section - from extension search
|
|
||||||
if (artistItems.isNotEmpty)
|
if (artistItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -967,7 +984,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Albums section
|
|
||||||
if (albumItems.isNotEmpty)
|
if (albumItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -1002,7 +1018,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Playlists section
|
|
||||||
if (playlistItems.isNotEmpty)
|
if (playlistItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -1037,14 +1052,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Songs section header
|
|
||||||
if (realTracks.isNotEmpty)
|
if (realTracks.isNotEmpty)
|
||||||
SliverToBoxAdapter(child: Padding(
|
SliverToBoxAdapter(child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Text(context.l10n.searchSongs, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Text(context.l10n.searchSongs, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
)),
|
)),
|
||||||
|
|
||||||
// Track list in grouped card
|
|
||||||
if (realTracks.isNotEmpty)
|
if (realTracks.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -1075,7 +1088,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom padding
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1108,7 +1120,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
||||||
// Validate image URL - must be non-null, non-empty, and have a valid host
|
|
||||||
final hasValidImage = artist.imageUrl != null &&
|
final hasValidImage = artist.imageUrl != null &&
|
||||||
artist.imageUrl!.isNotEmpty &&
|
artist.imageUrl!.isNotEmpty &&
|
||||||
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
|
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
|
||||||
@@ -1158,17 +1169,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToArtist(String artistId, String artistName, String? imageUrl) {
|
void _navigateToArtist(String artistId, String artistName, String? imageUrl) {
|
||||||
// Navigate immediately with data from search, fetch albums in ArtistScreen
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
|
||||||
// Recording is done in ArtistScreen.initState to avoid duplicates
|
|
||||||
|
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ArtistScreen(
|
builder: (context) => ArtistScreen(
|
||||||
artistId: artistId,
|
artistId: artistId,
|
||||||
artistName: artistName,
|
artistName: artistName,
|
||||||
coverUrl: imageUrl,
|
coverUrl: imageUrl,
|
||||||
// albums: null - will be fetched in ArtistScreen
|
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1184,7 +1191,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
|
||||||
// Record access for recent history
|
|
||||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
||||||
id: albumItem.id,
|
id: albumItem.id,
|
||||||
name: albumItem.name,
|
name: albumItem.name,
|
||||||
@@ -1193,7 +1199,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
providerId: extensionId,
|
providerId: extensionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Navigate to AlbumScreen - it will fetch tracks via extension
|
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ExtensionAlbumScreen(
|
builder: (context) => ExtensionAlbumScreen(
|
||||||
extensionId: extensionId,
|
extensionId: extensionId,
|
||||||
@@ -1215,7 +1220,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
|
||||||
// Record access for recent history
|
|
||||||
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
|
ref.read(recentAccessProvider.notifier).recordPlaylistAccess(
|
||||||
id: playlistItem.id,
|
id: playlistItem.id,
|
||||||
name: playlistItem.name,
|
name: playlistItem.name,
|
||||||
@@ -1224,7 +1228,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
providerId: extensionId,
|
providerId: extensionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Navigate to ExtensionPlaylistScreen - it will fetch tracks via extension
|
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ExtensionPlaylistScreen(
|
builder: (context) => ExtensionPlaylistScreen(
|
||||||
extensionId: extensionId,
|
extensionId: extensionId,
|
||||||
@@ -1246,7 +1249,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
|
||||||
// Record access for recent history
|
|
||||||
ref.read(recentAccessProvider.notifier).recordArtistAccess(
|
ref.read(recentAccessProvider.notifier).recordArtistAccess(
|
||||||
id: artistItem.id,
|
id: artistItem.id,
|
||||||
name: artistItem.name,
|
name: artistItem.name,
|
||||||
@@ -1254,7 +1256,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
providerId: extensionId,
|
providerId: extensionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Navigate to ExtensionArtistScreen - it will fetch albums via extension
|
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, MaterialPageRoute(
|
||||||
builder: (context) => ExtensionArtistScreen(
|
builder: (context) => ExtensionArtistScreen(
|
||||||
extensionId: extensionId,
|
extensionId: extensionId,
|
||||||
@@ -1271,22 +1272,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final searchProvider = settings.searchProvider;
|
final searchProvider = settings.searchProvider;
|
||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
|
|
||||||
// If extension system not initialized yet, show default hint
|
|
||||||
if (!extState.isInitialized) {
|
if (!extState.isInitialized) {
|
||||||
return 'Paste Spotify URL or search...';
|
return 'Paste Spotify URL or search...';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||||
final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull;
|
final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull;
|
||||||
// Only show extension placeholder if extension exists AND is enabled
|
|
||||||
if (ext != null && ext.enabled) {
|
if (ext != null && ext.enabled) {
|
||||||
if (ext.searchBehavior?.placeholder != null) {
|
if (ext.searchBehavior?.placeholder != null) {
|
||||||
return ext.searchBehavior!.placeholder!;
|
return ext.searchBehavior!.placeholder!;
|
||||||
}
|
}
|
||||||
return 'Search with ${ext.displayName}...';
|
return 'Search with ${ext.displayName}...';
|
||||||
}
|
}
|
||||||
// Extension not found or disabled - clear the search provider setting
|
|
||||||
// and return default hint
|
|
||||||
}
|
}
|
||||||
return 'Paste Spotify URL or search...';
|
return 'Paste Spotify URL or search...';
|
||||||
}
|
}
|
||||||
@@ -1346,17 +1343,19 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
/// Handle Enter key press - search or fetch URL
|
/// Handle Enter key press - search or fetch URL
|
||||||
void _onSearchSubmitted() {
|
void _onSearchSubmitted() {
|
||||||
|
// Cancel any pending live search since user explicitly pressed enter
|
||||||
|
_liveSearchDebounce?.cancel();
|
||||||
|
_pendingLiveSearchQuery = null;
|
||||||
|
|
||||||
final text = _urlController.text.trim();
|
final text = _urlController.text.trim();
|
||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
// If it's a URL, fetch metadata
|
|
||||||
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
if (text.startsWith('http') || text.startsWith('spotify:')) {
|
||||||
_fetchMetadata();
|
_fetchMetadata();
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For search queries, always search (minimum 2 chars)
|
|
||||||
if (text.length >= 2) {
|
if (text.length >= 2) {
|
||||||
_performSearch(text);
|
_performSearch(text);
|
||||||
}
|
}
|
||||||
@@ -1384,21 +1383,17 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Only watch the specific item for this track using select()
|
|
||||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Check if track is in history (already downloaded before)
|
|
||||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
return state.isDownloaded(track.id);
|
return state.isDownloaded(track.id);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get thumbnail size from extension if track is from extension
|
|
||||||
double thumbWidth = 56;
|
double thumbWidth = 56;
|
||||||
double thumbHeight = 56;
|
double thumbHeight = 56;
|
||||||
|
|
||||||
// Get extension ID from track.source or from TrackState.searchExtensionId
|
|
||||||
final trackState = ref.watch(trackProvider);
|
final trackState = ref.watch(trackProvider);
|
||||||
final extensionId = track.source ?? trackState.searchExtensionId;
|
final extensionId = track.source ?? trackState.searchExtensionId;
|
||||||
|
|
||||||
@@ -1409,7 +1404,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
final size = extension!.searchBehavior!.getThumbnailSize(defaultSize: 56);
|
final size = extension!.searchBehavior!.getThumbnailSize(defaultSize: 56);
|
||||||
thumbWidth = size.$1;
|
thumbWidth = size.$1;
|
||||||
thumbHeight = size.$2;
|
thumbHeight = size.$2;
|
||||||
// Debug: log only when using custom size
|
|
||||||
if (thumbWidth != 56 || thumbHeight != 56) {
|
if (thumbWidth != 56 || thumbHeight != 56) {
|
||||||
debugPrint('[Thumbnail] ${track.name}: using ${thumbWidth.toInt()}x${thumbHeight.toInt()} from ${extension.id}');
|
debugPrint('[Thumbnail] ${track.name}: using ${thumbWidth.toInt()}x${thumbHeight.toInt()} from ${extension.id}');
|
||||||
}
|
}
|
||||||
@@ -1422,7 +1416,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
final progress = queueItem?.progress ?? 0.0;
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
// Show as downloaded if in queue completed OR in history
|
|
||||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -1436,7 +1429,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Album art with dynamic size based on extension config
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: track.coverUrl != null
|
child: track.coverUrl != null
|
||||||
@@ -1456,7 +1448,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Track info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1477,7 +1468,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Download button / status indicator
|
|
||||||
_buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
_buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1496,16 +1486,13 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||||
// If already in queue, do nothing
|
|
||||||
if (isQueued) return;
|
if (isQueued) return;
|
||||||
|
|
||||||
// If in history, check if file still exists
|
|
||||||
if (isInHistory) {
|
if (isInHistory) {
|
||||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||||
if (historyItem != null) {
|
if (historyItem != null) {
|
||||||
final fileExists = await File(historyItem.filePath).exists();
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
// File exists, show snackbar
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
|
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
|
||||||
@@ -1513,13 +1500,11 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// File doesn't exist, remove from history and allow download
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proceed with download
|
|
||||||
onDownload();
|
onDownload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1545,7 +1530,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (isFinalizing) {
|
} else if (isFinalizing) {
|
||||||
// Show finalizing status (embedding metadata)
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -1609,7 +1593,6 @@ class _CollectionItemWidget extends StatelessWidget {
|
|||||||
final isPlaylist = item.isPlaylistItem;
|
final isPlaylist = item.isPlaylistItem;
|
||||||
final isArtist = item.isArtistItem;
|
final isArtist = item.isArtistItem;
|
||||||
|
|
||||||
// Determine icon for placeholder
|
|
||||||
IconData placeholderIcon = Icons.album;
|
IconData placeholderIcon = Icons.album;
|
||||||
if (isPlaylist) placeholderIcon = Icons.playlist_play;
|
if (isPlaylist) placeholderIcon = Icons.playlist_play;
|
||||||
if (isArtist) placeholderIcon = Icons.person;
|
if (isArtist) placeholderIcon = Icons.person;
|
||||||
@@ -1625,7 +1608,6 @@ class _CollectionItemWidget extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Cover art (circular for artists)
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(isArtist ? 28 : 10),
|
borderRadius: BorderRadius.circular(isArtist ? 28 : 10),
|
||||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||||
@@ -1648,7 +1630,6 @@ class _CollectionItemWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1669,7 +1650,6 @@ class _CollectionItemWidget extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Arrow indicator
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.chevron_right,
|
Icons.chevron_right,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -1743,7 +1723,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse tracks from result
|
|
||||||
final trackList = result['tracks'] as List<dynamic>?;
|
final trackList = result['tracks'] as List<dynamic>?;
|
||||||
if (trackList == null) {
|
if (trackList == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -1820,7 +1799,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to AlbumScreen with fetched tracks
|
|
||||||
return AlbumScreen(
|
return AlbumScreen(
|
||||||
albumId: widget.albumId,
|
albumId: widget.albumId,
|
||||||
albumName: widget.albumName,
|
albumName: widget.albumName,
|
||||||
@@ -1881,7 +1859,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState<ExtensionPlaylistScree
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse tracks from result
|
|
||||||
final trackList = result['tracks'] as List<dynamic>?;
|
final trackList = result['tracks'] as List<dynamic>?;
|
||||||
if (trackList == null) {
|
if (trackList == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -1958,7 +1935,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState<ExtensionPlaylistScree
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to PlaylistScreen with fetched tracks
|
|
||||||
return PlaylistScreen(
|
return PlaylistScreen(
|
||||||
playlistName: widget.playlistName,
|
playlistName: widget.playlistName,
|
||||||
coverUrl: widget.coverUrl,
|
coverUrl: widget.coverUrl,
|
||||||
@@ -2021,18 +1997,15 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse albums from result
|
|
||||||
final albumList = result['albums'] as List<dynamic>?;
|
final albumList = result['albums'] as List<dynamic>?;
|
||||||
final albums = albumList?.map((a) => _parseAlbum(a as Map<String, dynamic>)).toList() ?? [];
|
final albums = albumList?.map((a) => _parseAlbum(a as Map<String, dynamic>)).toList() ?? [];
|
||||||
|
|
||||||
// Parse top tracks from result
|
|
||||||
final topTracksList = result['top_tracks'] as List<dynamic>?;
|
final topTracksList = result['top_tracks'] as List<dynamic>?;
|
||||||
List<Track>? topTracks;
|
List<Track>? topTracks;
|
||||||
if (topTracksList != null && topTracksList.isNotEmpty) {
|
if (topTracksList != null && topTracksList.isNotEmpty) {
|
||||||
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse additional artist info
|
|
||||||
final headerImage = result['header_image'] as String?;
|
final headerImage = result['header_image'] as String?;
|
||||||
final listeners = result['listeners'] as int?;
|
final listeners = result['listeners'] as int?;
|
||||||
|
|
||||||
@@ -2115,7 +2088,6 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to ArtistScreen with fetched albums and top tracks
|
|
||||||
return ArtistScreen(
|
return ArtistScreen(
|
||||||
artistId: widget.artistId,
|
artistId: widget.artistId,
|
||||||
artistName: widget.artistName,
|
artistName: widget.artistName,
|
||||||
|
|||||||
@@ -30,13 +30,12 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
bool _hasCheckedUpdate = false;
|
bool _hasCheckedUpdate = false;
|
||||||
StreamSubscription<String>? _shareSubscription;
|
StreamSubscription<String>? _shareSubscription;
|
||||||
DateTime? _lastBackPress; // For double-tap to exit
|
DateTime? _lastBackPress;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageController = PageController(initialPage: _currentIndex);
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
// Check for updates after first frame
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_checkForUpdates();
|
_checkForUpdates();
|
||||||
_setupShareListener();
|
_setupShareListener();
|
||||||
@@ -44,14 +43,12 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _setupShareListener() {
|
void _setupShareListener() {
|
||||||
// Check for pending URL that was received before listener was ready
|
|
||||||
final pendingUrl = ShareIntentService().consumePendingUrl();
|
final pendingUrl = ShareIntentService().consumePendingUrl();
|
||||||
if (pendingUrl != null) {
|
if (pendingUrl != null) {
|
||||||
_log.d('Processing pending shared URL: $pendingUrl');
|
_log.d('Processing pending shared URL: $pendingUrl');
|
||||||
_handleSharedUrl(pendingUrl);
|
_handleSharedUrl(pendingUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for future shared URLs with error handling
|
|
||||||
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
||||||
(url) {
|
(url) {
|
||||||
_log.d('Received shared URL from stream: $url');
|
_log.d('Received shared URL from stream: $url');
|
||||||
@@ -65,18 +62,13 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleSharedUrl(String url) {
|
void _handleSharedUrl(String url) {
|
||||||
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
|
||||||
// Navigate to Home tab
|
|
||||||
if (_currentIndex != 0) {
|
if (_currentIndex != 0) {
|
||||||
_onNavTap(0);
|
_onNavTap(0);
|
||||||
}
|
}
|
||||||
// Fetch metadata for shared URL
|
|
||||||
ref.read(trackProvider.notifier).fetchFromUrl(url);
|
ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
// Mark that user has searched (hide helper text)
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
// Show snackbar
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.loadingSharedLink)),
|
SnackBar(content: Text(context.l10n.loadingSharedLink)),
|
||||||
@@ -124,8 +116,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
void _onPageChanged(int index) {
|
void _onPageChanged(int index) {
|
||||||
if (_currentIndex != index) {
|
if (_currentIndex != index) {
|
||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
// Unfocus any text field when switching tabs to prevent keyboard from appearing
|
|
||||||
// Use primaryFocus for more aggressive unfocus that works with keep-alive widgets
|
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,39 +124,32 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
void _handleBackPress() {
|
void _handleBackPress() {
|
||||||
final trackState = ref.read(trackProvider);
|
final trackState = ref.read(trackProvider);
|
||||||
|
|
||||||
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
|
||||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
if (isKeyboardVisible) {
|
if (isKeyboardVisible) {
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If on Home tab and showing recent access mode, exit it
|
|
||||||
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
||||||
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||||
// Also unfocus search bar when exiting recent access mode
|
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
|
||||||
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not on Home tab, go to Home tab first
|
|
||||||
if (_currentIndex != 0) {
|
if (_currentIndex != 0) {
|
||||||
_onNavTap(0);
|
_onNavTap(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If loading, ignore back press
|
|
||||||
if (trackState.isLoading) {
|
if (trackState.isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Double-tap to exit
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
||||||
SystemNavigator.pop();
|
SystemNavigator.pop();
|
||||||
@@ -189,7 +172,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
||||||
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
|
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
|
||||||
|
|
||||||
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
|
||||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
|
||||||
// Determine if we can pop (for predictive back animation)
|
// Determine if we can pop (for predictive back animation)
|
||||||
@@ -202,7 +184,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
!trackState.isShowingRecentAccess &&
|
!trackState.isShowingRecentAccess &&
|
||||||
!isKeyboardVisible;
|
!isKeyboardVisible;
|
||||||
|
|
||||||
// Build tabs and destinations based on settings
|
|
||||||
final tabs = <Widget>[
|
final tabs = <Widget>[
|
||||||
const HomeTab(),
|
const HomeTab(),
|
||||||
QueueTab(
|
QueueTab(
|
||||||
@@ -255,7 +236,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Clamp current index if tabs changed
|
|
||||||
final maxIndex = tabs.length - 1;
|
final maxIndex = tabs.length - 1;
|
||||||
if (_currentIndex > maxIndex) {
|
if (_currentIndex > maxIndex) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -275,7 +255,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle back press manually when canPop is false
|
|
||||||
_handleBackPress();
|
_handleBackPress();
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
|||||||
@@ -217,12 +217,10 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Only watch the specific item for this track
|
|
||||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Check if track is in history (already downloaded before)
|
|
||||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
return state.isDownloaded(track.id);
|
return state.isDownloaded(track.id);
|
||||||
}));
|
}));
|
||||||
@@ -233,7 +231,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
final progress = queueItem?.progress ?? 0.0;
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
// Show as downloaded if in queue completed OR in history
|
|
||||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|||||||
+14
-64
@@ -52,11 +52,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final Set<String> _pendingChecks = {};
|
final Set<String> _pendingChecks = {};
|
||||||
static const int _maxCacheSize = 500;
|
static const int _maxCacheSize = 500;
|
||||||
|
|
||||||
// Multi-select state
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
// Filter page controller for swipe between All/Albums/Singles
|
|
||||||
PageController? _filterPageController;
|
PageController? _filterPageController;
|
||||||
final List<String> _filterModes = ['all', 'albums', 'singles'];
|
final List<String> _filterModes = ['all', 'albums', 'singles'];
|
||||||
bool _isPageControllerInitialized = false;
|
bool _isPageControllerInitialized = false;
|
||||||
@@ -66,7 +64,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Will be initialized in build when we have access to ref
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initializePageController() {
|
void _initializePageController() {
|
||||||
@@ -291,7 +288,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
) {
|
) {
|
||||||
if (filterMode == 'all') return items;
|
if (filterMode == 'all') return items;
|
||||||
|
|
||||||
// Count tracks per album
|
|
||||||
final albumCounts = <String, int>{};
|
final albumCounts = <String, int>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
@@ -300,14 +296,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
switch (filterMode) {
|
switch (filterMode) {
|
||||||
case 'albums':
|
case 'albums':
|
||||||
// Album = more than 1 track from same album in history
|
|
||||||
return items.where((item) {
|
return items.where((item) {
|
||||||
final key =
|
final key =
|
||||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
return (albumCounts[key] ?? 0) > 1;
|
return (albumCounts[key] ?? 0) > 1;
|
||||||
}).toList();
|
}).toList();
|
||||||
case 'singles':
|
case 'singles':
|
||||||
// Single = only 1 track from that album in history
|
|
||||||
return items.where((item) {
|
return items.where((item) {
|
||||||
final key =
|
final key =
|
||||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
@@ -320,7 +314,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
/// Count albums vs singles for filter chips
|
/// Count albums vs singles for filter chips
|
||||||
Map<String, int> _countAlbumsAndSingles(List<DownloadHistoryItem> items) {
|
Map<String, int> _countAlbumsAndSingles(List<DownloadHistoryItem> items) {
|
||||||
// Count tracks per album
|
|
||||||
final albumCounts = <String, int>{};
|
final albumCounts = <String, int>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
@@ -351,11 +344,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
albumMap.putIfAbsent(key, () => []).add(item);
|
albumMap.putIfAbsent(key, () => []).add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include albums with more than 1 track
|
|
||||||
final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map(
|
final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map(
|
||||||
(e) {
|
(e) {
|
||||||
final tracks = e.value;
|
final tracks = e.value;
|
||||||
// Sort tracks by track number
|
|
||||||
tracks.sort((a, b) {
|
tracks.sort((a, b) {
|
||||||
final aNum = a.trackNumber ?? 999;
|
final aNum = a.trackNumber ?? 999;
|
||||||
final bNum = b.trackNumber ?? 999;
|
final bNum = b.trackNumber ?? 999;
|
||||||
@@ -374,7 +365,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
},
|
},
|
||||||
).toList();
|
).toList();
|
||||||
|
|
||||||
// Sort by latest download
|
|
||||||
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
|
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
|
||||||
|
|
||||||
return groupedAlbums;
|
return groupedAlbums;
|
||||||
@@ -388,7 +378,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
albumKeys.add(key);
|
albumKeys.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count albums with more than 1 track
|
|
||||||
int count = 0;
|
int count = 0;
|
||||||
for (final key in albumKeys) {
|
for (final key in albumKeys) {
|
||||||
final trackCount = items
|
final trackCount = items
|
||||||
@@ -421,7 +410,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Initialize page controller on first build
|
|
||||||
_initializePageController();
|
_initializePageController();
|
||||||
|
|
||||||
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||||
@@ -447,10 +435,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
// Group albums for Albums filter view
|
|
||||||
final groupedAlbums = _groupByAlbum(allHistoryItems);
|
final groupedAlbums = _groupByAlbum(allHistoryItems);
|
||||||
|
|
||||||
// Count for filter chips
|
|
||||||
final counts = _countAlbumsAndSingles(allHistoryItems);
|
final counts = _countAlbumsAndSingles(allHistoryItems);
|
||||||
final albumCount = _countUniqueAlbums(allHistoryItems);
|
final albumCount = _countUniqueAlbums(allHistoryItems);
|
||||||
final singleCount = counts['singles'] ?? 0;
|
final singleCount = counts['singles'] ?? 0;
|
||||||
@@ -466,9 +452,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
NestedScrollView(
|
// ScrollConfiguration disables stretch overscroll to fix _StretchController exception
|
||||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
// This is a known Flutter issue with NestedScrollView + Material 3 stretch indicator
|
||||||
// App Bar - always normal style
|
ScrollConfiguration(
|
||||||
|
behavior: ScrollConfiguration.of(context).copyWith(
|
||||||
|
overscroll: false,
|
||||||
|
),
|
||||||
|
child: NestedScrollView(
|
||||||
|
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -502,7 +493,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Pause/Resume controls
|
|
||||||
if ((isProcessing || queuedCount > 0) &&
|
if ((isProcessing || queuedCount > 0) &&
|
||||||
(queueItems.length > 1 || isPaused))
|
(queueItems.length > 1 || isPaused))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -548,10 +538,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Queue header
|
|
||||||
if (queueItems.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -562,10 +551,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Queue list
|
|
||||||
if (queueItems.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
@@ -577,7 +565,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}, childCount: queueItems.length),
|
}, childCount: queueItems.length),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Filter chips (only show when history has items)
|
|
||||||
if (allHistoryItems.isNotEmpty)
|
if (allHistoryItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -630,7 +617,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (notification is OverscrollNotification) {
|
if (notification is OverscrollNotification) {
|
||||||
final overscroll = notification.overscroll;
|
final overscroll = notification.overscroll;
|
||||||
|
|
||||||
// At first page and overscrolling to the left -> push parent toward Home
|
|
||||||
if (page == 0 && overscroll < 0) {
|
if (page == 0 && overscroll < 0) {
|
||||||
final currentOffset = parentController.offset;
|
final currentOffset = parentController.offset;
|
||||||
final targetOffset = (currentOffset + overscroll).clamp(
|
final targetOffset = (currentOffset + overscroll).clamp(
|
||||||
@@ -641,7 +627,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// At last page and overscrolling to the right -> push parent toward next tab
|
|
||||||
if (page == 2 && overscroll > 0) {
|
if (page == 2 && overscroll > 0) {
|
||||||
final currentOffset = parentController.offset;
|
final currentOffset = parentController.offset;
|
||||||
final targetOffset = (currentOffset + overscroll).clamp(
|
final targetOffset = (currentOffset + overscroll).clamp(
|
||||||
@@ -653,32 +638,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snap parent to nearest page when scroll ends
|
|
||||||
if (notification is ScrollEndNotification) {
|
if (notification is ScrollEndNotification) {
|
||||||
if (page == 0 || page == 2) {
|
if (page == 0 || page == 2) {
|
||||||
final currentPage = parentController.page ?? widget.parentPageIndex.toDouble();
|
final currentPage = parentController.page ?? widget.parentPageIndex.toDouble();
|
||||||
final historyPage = widget.parentPageIndex.toDouble();
|
final historyPage = widget.parentPageIndex.toDouble();
|
||||||
final offset = currentPage - historyPage;
|
final offset = currentPage - historyPage;
|
||||||
|
|
||||||
// Only snap if we've moved the parent
|
|
||||||
if (offset.abs() > 0.01) {
|
if (offset.abs() > 0.01) {
|
||||||
// Use 0.3 threshold (30%)
|
|
||||||
if (offset < -0.3) {
|
if (offset < -0.3) {
|
||||||
// Swiped enough toward Home - animate to Home
|
|
||||||
parentController.animateToPage(
|
parentController.animateToPage(
|
||||||
widget.parentPageIndex - 1,
|
widget.parentPageIndex - 1,
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
);
|
);
|
||||||
} else if (offset > 0.3) {
|
} else if (offset > 0.3) {
|
||||||
// Swiped enough toward next tab - animate to next
|
|
||||||
parentController.animateToPage(
|
parentController.animateToPage(
|
||||||
widget.nextPageIndex ?? (widget.parentPageIndex + 1),
|
widget.nextPageIndex ?? (widget.parentPageIndex + 1),
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Not enough - instant jump back (no animation)
|
|
||||||
parentController.jumpToPage(widget.parentPageIndex);
|
parentController.jumpToPage(widget.parentPageIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,7 +671,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
onPageChanged: _onFilterPageChanged,
|
onPageChanged: _onFilterPageChanged,
|
||||||
children: [
|
children: [
|
||||||
// All tab
|
|
||||||
_buildFilterContent(
|
_buildFilterContent(
|
||||||
context: context,
|
context: context,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
@@ -702,7 +680,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
queueItems: queueItems,
|
queueItems: queueItems,
|
||||||
groupedAlbums: groupedAlbums,
|
groupedAlbums: groupedAlbums,
|
||||||
),
|
),
|
||||||
// Albums tab
|
|
||||||
_buildFilterContent(
|
_buildFilterContent(
|
||||||
context: context,
|
context: context,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
@@ -712,7 +689,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
queueItems: queueItems,
|
queueItems: queueItems,
|
||||||
groupedAlbums: groupedAlbums,
|
groupedAlbums: groupedAlbums,
|
||||||
),
|
),
|
||||||
// Singles tab
|
|
||||||
_buildFilterContent(
|
_buildFilterContent(
|
||||||
context: context,
|
context: context,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
@@ -726,8 +702,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
), // ScrollConfiguration
|
||||||
|
|
||||||
// Bottom Selection Action Bar
|
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
@@ -760,7 +736,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// History section header
|
|
||||||
if (historyItems.isNotEmpty &&
|
if (historyItems.isNotEmpty &&
|
||||||
queueItems.isEmpty &&
|
queueItems.isEmpty &&
|
||||||
filterMode != 'albums')
|
filterMode != 'albums')
|
||||||
@@ -791,7 +766,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Albums section header (when Albums filter is selected)
|
|
||||||
if (groupedAlbums.isNotEmpty &&
|
if (groupedAlbums.isNotEmpty &&
|
||||||
queueItems.isEmpty &&
|
queueItems.isEmpty &&
|
||||||
filterMode == 'albums')
|
filterMode == 'albums')
|
||||||
@@ -807,7 +781,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// History section header when queue has items
|
|
||||||
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
|
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -821,7 +794,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Albums Grid (when Albums filter is selected)
|
|
||||||
if (filterMode == 'albums' && groupedAlbums.isNotEmpty)
|
if (filterMode == 'albums' && groupedAlbums.isNotEmpty)
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
@@ -843,7 +815,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// History - Grid or List (for All and Singles filter)
|
|
||||||
if (historyItems.isNotEmpty && filterMode != 'albums')
|
if (historyItems.isNotEmpty && filterMode != 'albums')
|
||||||
historyViewMode == 'grid'
|
historyViewMode == 'grid'
|
||||||
? SliverPadding(
|
? SliverPadding(
|
||||||
@@ -883,10 +854,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
colorScheme,
|
colorScheme,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: historyItems.length),
|
}, childCount: historyItems.length ),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Empty state
|
|
||||||
if (queueItems.isEmpty &&
|
if (queueItems.isEmpty &&
|
||||||
historyItems.isEmpty &&
|
historyItems.isEmpty &&
|
||||||
(filterMode != 'albums' || groupedAlbums.isEmpty))
|
(filterMode != 'albums' || groupedAlbums.isEmpty))
|
||||||
@@ -899,7 +869,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
// Add bottom padding when selection mode is active to avoid overlap with bottom bar
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: _isSelectionMode ? 100 : 16),
|
child: SizedBox(height: _isSelectionMode ? 100 : 16),
|
||||||
),
|
),
|
||||||
@@ -968,7 +937,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Album cover with track count badge
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -994,7 +962,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Track count badge
|
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 8,
|
right: 8,
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
@@ -1032,16 +999,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Album name
|
|
||||||
Text(
|
Text(
|
||||||
album.albumName,
|
album.albumName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600 ),
|
||||||
),
|
),
|
||||||
// Artist name
|
|
||||||
Text(
|
Text(
|
||||||
album.artistName,
|
album.artistName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -1085,7 +1050,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Handle bar
|
|
||||||
Container(
|
Container(
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 4,
|
height: 4,
|
||||||
@@ -1096,10 +1060,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Selection info row
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Close button
|
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: _exitSelectionMode,
|
onPressed: _exitSelectionMode,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
@@ -1109,7 +1071,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Selection count
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1130,7 +1091,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Select all toggle
|
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
@@ -1153,7 +1113,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Delete button
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
@@ -1461,7 +1420,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Quality badge
|
|
||||||
if (item.quality != null && item.quality!.contains('bit'))
|
if (item.quality != null && item.quality!.contains('bit'))
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 4,
|
left: 4,
|
||||||
@@ -1490,7 +1448,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Play button
|
|
||||||
if (fileExists && !_isSelectionMode)
|
if (fileExists && !_isSelectionMode)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 4,
|
right: 4,
|
||||||
@@ -1511,7 +1468,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Error indicator
|
|
||||||
if (!fileExists && !_isSelectionMode)
|
if (!fileExists && !_isSelectionMode)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 4,
|
right: 4,
|
||||||
@@ -1529,7 +1485,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Selection overlay
|
|
||||||
if (_isSelectionMode)
|
if (_isSelectionMode)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -1562,7 +1517,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Selection checkbox
|
|
||||||
if (_isSelectionMode)
|
if (_isSelectionMode)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 4,
|
right: 4,
|
||||||
@@ -1630,7 +1584,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Selection checkbox
|
|
||||||
if (_isSelectionMode) ...[
|
if (_isSelectionMode) ...[
|
||||||
Container(
|
Container(
|
||||||
width: 24,
|
width: 24,
|
||||||
@@ -1657,7 +1610,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
// Cover art
|
|
||||||
item.coverUrl != null
|
item.coverUrl != null
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -1684,7 +1636,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Track info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1752,7 +1703,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Action buttons (hide in selection mode)
|
|
||||||
if (!_isSelectionMode)
|
if (!_isSelectionMode)
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -35,9 +34,7 @@ class AboutPage extends StatelessWidget {
|
|||||||
final maxHeight = 120 + topPadding;
|
final maxHeight = 120 + topPadding;
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
// When collapsed (expandRatio=0): left=56 to avoid back button
|
final leftPadding = 56 - (32 * expandRatio);
|
||||||
// When expanded (expandRatio=1): left=24 for normal padding
|
|
||||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
@@ -54,7 +51,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// App header card with logo and description
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
@@ -62,7 +58,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Contributors section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
|
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
|
||||||
),
|
),
|
||||||
@@ -91,7 +86,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Special Thanks section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
|
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
|
||||||
),
|
),
|
||||||
@@ -128,7 +122,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Links section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
|
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
|
||||||
),
|
),
|
||||||
@@ -167,7 +160,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Support section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
||||||
),
|
),
|
||||||
@@ -185,7 +177,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// App info section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.aboutApp),
|
child: SettingsSectionHeader(title: context.l10n.aboutApp),
|
||||||
),
|
),
|
||||||
@@ -202,7 +193,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Copyright
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -217,7 +207,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom padding
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -227,7 +216,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
|
|
||||||
static Future<void> _launchUrl(String url) async {
|
static Future<void> _launchUrl(String url) async {
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri.parse(url);
|
||||||
// Use inAppBrowserView for reliable URL opening with app chooser
|
|
||||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,8 +238,6 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// App logo
|
|
||||||
// App logo
|
|
||||||
Container(
|
Container(
|
||||||
width: 88,
|
width: 88,
|
||||||
height: 88,
|
height: 88,
|
||||||
@@ -275,7 +261,6 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// App name
|
|
||||||
Text(
|
Text(
|
||||||
AppInfo.appName,
|
AppInfo.appName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
@@ -283,7 +268,6 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// Version badge
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -299,7 +283,6 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Description
|
|
||||||
Text(
|
Text(
|
||||||
context.l10n.aboutAppDescription,
|
context.l10n.aboutAppDescription,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -341,7 +324,6 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// GitHub Avatar
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
@@ -372,7 +354,6 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Name and description
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -391,7 +372,6 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// GitHub icon
|
|
||||||
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -446,7 +426,6 @@ class _AboutSettingsItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Icon with 40x40 size to match avatar
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -39,7 +38,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Preview Section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -50,7 +48,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Color section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionColor),
|
child: SettingsSectionHeader(title: context.l10n.sectionColor),
|
||||||
),
|
),
|
||||||
@@ -80,10 +77,9 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
onColorSelected: (color) =>
|
onColorSelected: (color) =>
|
||||||
ref.read(themeProvider.notifier).setSeedColor(color),
|
ref.read(themeProvider.notifier).setSeedColor(color),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Theme section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
|
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
|
||||||
),
|
),
|
||||||
@@ -109,7 +105,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Language section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionLanguage),
|
child: SettingsSectionHeader(title: context.l10n.sectionLanguage),
|
||||||
),
|
),
|
||||||
@@ -126,7 +121,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Layout section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
|
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
|
||||||
),
|
),
|
||||||
@@ -143,7 +137,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Fill remaining for scroll
|
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
child: SizedBox(height: 32),
|
child: SizedBox(height: 32),
|
||||||
@@ -174,7 +167,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Decorative background blobs
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: -50,
|
top: -50,
|
||||||
right: -50,
|
right: -50,
|
||||||
@@ -200,7 +192,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Foreground "fake UI"
|
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -219,7 +210,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Fake Album Art
|
|
||||||
Container(
|
Container(
|
||||||
width: 108,
|
width: 108,
|
||||||
height: 108,
|
height: 108,
|
||||||
@@ -235,7 +225,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
// Fake Text Info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -288,7 +277,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Label badge
|
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
@@ -510,10 +498,7 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Unselected chips need contrast with card background
|
|
||||||
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
|
||||||
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(
|
? Color.alphaBlend(
|
||||||
Colors.white.withValues(alpha: 0.05),
|
Colors.white.withValues(alpha: 0.05),
|
||||||
@@ -640,7 +625,6 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Unselected chips need contrast with card background
|
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(
|
? Color.alphaBlend(
|
||||||
Colors.white.withValues(alpha: 0.05),
|
Colors.white.withValues(alpha: 0.05),
|
||||||
@@ -710,7 +694,6 @@ class _LanguageSelector extends StatelessWidget {
|
|||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
// All available languages (code, displayName, icon)
|
|
||||||
static const _allLanguages = [
|
static const _allLanguages = [
|
||||||
('system', 'System Default', Icons.phone_android),
|
('system', 'System Default', Icons.phone_android),
|
||||||
('en', 'English', Icons.language),
|
('en', 'English', Icons.language),
|
||||||
@@ -732,15 +715,12 @@ class _LanguageSelector extends StatelessWidget {
|
|||||||
/// Uses filteredLocaleCodes from supported_locales.dart (generated file).
|
/// Uses filteredLocaleCodes from supported_locales.dart (generated file).
|
||||||
List<(String, String, IconData)> get _languages {
|
List<(String, String, IconData)> get _languages {
|
||||||
return _allLanguages.where((lang) {
|
return _allLanguages.where((lang) {
|
||||||
// Always include 'system' option
|
|
||||||
if (lang.$1 == 'system') return true;
|
if (lang.$1 == 'system') return true;
|
||||||
// Only include languages in the filtered set
|
|
||||||
return filteredLocaleCodes.contains(lang.$1);
|
return filteredLocaleCodes.contains(lang.$1);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getLanguageName(String code) {
|
String _getLanguageName(String code) {
|
||||||
// Search in all languages (not just filtered) for display name fallback
|
|
||||||
for (final lang in _allLanguages) {
|
for (final lang in _allLanguages) {
|
||||||
if (lang.$1 == code) return lang.$2;
|
if (lang.$1 == code) return lang.$2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
|
|||||||
class DownloadSettingsPage extends ConsumerWidget {
|
class DownloadSettingsPage extends ConsumerWidget {
|
||||||
const DownloadSettingsPage({super.key});
|
const DownloadSettingsPage({super.key});
|
||||||
|
|
||||||
// Built-in services that support quality options
|
|
||||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -20,7 +19,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
// Check if current service is built-in (supports quality options)
|
|
||||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -28,7 +26,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -68,7 +65,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Service section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionService),
|
child: SettingsSectionHeader(title: context.l10n.sectionService),
|
||||||
),
|
),
|
||||||
@@ -85,7 +81,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Quality section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
|
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
|
||||||
),
|
),
|
||||||
@@ -99,7 +94,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
? context.l10n.downloadAskQualitySubtitle
|
? context.l10n.downloadAskQualitySubtitle
|
||||||
: 'Select a built-in service to enable',
|
: 'Select a built-in service to enable',
|
||||||
value: settings.askQualityBeforeDownload,
|
value: settings.askQualityBeforeDownload,
|
||||||
// Not selected visually if extension is active
|
|
||||||
enabled: isBuiltInService,
|
enabled: isBuiltInService,
|
||||||
onChanged: (value) => ref
|
onChanged: (value) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -159,7 +153,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// File settings section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
|
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
|
||||||
),
|
),
|
||||||
@@ -321,11 +314,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
String insertion = tag;
|
String insertion = tag;
|
||||||
if (start > 0) {
|
if (start > 0) {
|
||||||
final before = text.substring(0, start);
|
final before = text.substring(0, start);
|
||||||
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
|
|
||||||
if (!before.trim().endsWith('-')) {
|
if (!before.trim().endsWith('-')) {
|
||||||
insertion = ' - $tag';
|
insertion = ' - $tag';
|
||||||
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
||||||
// If ends with '-' but no space, add space
|
|
||||||
insertion = ' $tag';
|
insertion = ' $tag';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,10 +469,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
|
|
||||||
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
// iOS: Show options dialog
|
|
||||||
_showIOSDirectoryOptions(context, ref);
|
_showIOSDirectoryOptions(context, ref);
|
||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
@@ -697,18 +686,15 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
|
|
||||||
// Get enabled extension download providers
|
|
||||||
final extensionProviders = extState.extensions
|
final extensionProviders = extState.extensions
|
||||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Check if current service is an extension that's now disabled
|
|
||||||
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
|
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
|
||||||
final isCurrentExtensionEnabled = isExtensionService
|
final isCurrentExtensionEnabled = isExtensionService
|
||||||
? extensionProviders.any((e) => e.id == currentService)
|
? extensionProviders.any((e) => e.id == currentService)
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
// If current extension is disabled, show it as not selected
|
|
||||||
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
|
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -739,7 +725,6 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Show extension download providers if any
|
|
||||||
if (extensionProviders.isNotEmpty) ...[
|
if (extensionProviders.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
@@ -755,7 +740,6 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
// Fill remaining space if less than 3 extensions
|
|
||||||
for (int i = extensionProviders.length; i < 3; i++) ...[
|
for (int i = extensionProviders.length; i < 3; i++) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Expanded(child: SizedBox()),
|
const Expanded(child: SizedBox()),
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -98,7 +97,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Extension Info Card
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -202,7 +200,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Capabilities
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
|
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
|
||||||
),
|
),
|
||||||
@@ -254,9 +251,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// URL Handler Section (if extension handles URLs)
|
|
||||||
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
|
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
|
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
|
||||||
@@ -272,7 +266,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quality Options Section (for download providers)
|
|
||||||
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
|
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
|
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
|
||||||
@@ -291,7 +284,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Post-Processing Hooks (if available)
|
|
||||||
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
|
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
|
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
|
||||||
@@ -310,7 +302,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Permissions
|
|
||||||
if (extension.permissions.isNotEmpty) ...[
|
if (extension.permissions.isNotEmpty) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
|
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
|
||||||
@@ -329,7 +320,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Settings
|
|
||||||
if (extension.settings.isNotEmpty) ...[
|
if (extension.settings.isNotEmpty) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
|
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
|
||||||
@@ -358,7 +348,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Remove button
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -424,7 +413,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
.read(extensionProvider.notifier)
|
.read(extensionProvider.notifier)
|
||||||
.removeExtension(widget.extensionId);
|
.removeExtension(widget.extensionId);
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
// Refresh store to update isInstalled status
|
|
||||||
ref.read(storeProvider.notifier).refresh();
|
ref.read(storeProvider.notifier).refresh();
|
||||||
Navigator.pop(this.context);
|
Navigator.pop(this.context);
|
||||||
}
|
}
|
||||||
@@ -557,7 +545,6 @@ class _PermissionItem extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Parse permission to get icon and description
|
|
||||||
IconData icon = Icons.security;
|
IconData icon = Icons.security;
|
||||||
String description = permission;
|
String description = permission;
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
final extensionsDir = '${appDir.path}/extensions';
|
final extensionsDir = '${appDir.path}/extensions';
|
||||||
final dataDir = '${appDir.path}/extension_data';
|
final dataDir = '${appDir.path}/extension_data';
|
||||||
|
|
||||||
// Create directories if they don't exist
|
|
||||||
await Directory(extensionsDir).create(recursive: true);
|
await Directory(extensionsDir).create(recursive: true);
|
||||||
await Directory(dataDir).create(recursive: true);
|
await Directory(dataDir).create(recursive: true);
|
||||||
|
|
||||||
@@ -51,7 +50,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -87,7 +85,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Loading indicator
|
|
||||||
if (extState.isLoading)
|
if (extState.isLoading)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -96,7 +93,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error message
|
|
||||||
if (extState.error != null)
|
if (extState.error != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -123,7 +119,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Provider Priority
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
|
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
|
||||||
),
|
),
|
||||||
@@ -137,7 +132,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Installed Extensions
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
|
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
|
||||||
),
|
),
|
||||||
@@ -203,7 +197,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Install button
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -221,7 +214,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Info section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||||
@@ -284,11 +276,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
if (success) {
|
if (success) {
|
||||||
message = context.l10n.extensionsInstalledSuccess;
|
message = context.l10n.extensionsInstalledSuccess;
|
||||||
} else {
|
} else {
|
||||||
// Parse friendly error message
|
|
||||||
message = _getFriendlyErrorMessage(extState.error);
|
message = _getFriendlyErrorMessage(extState.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the error from state to avoid showing it twice (in error container)
|
|
||||||
ref.read(extensionProvider.notifier).clearError();
|
ref.read(extensionProvider.notifier).clearError();
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -305,15 +295,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
|
|
||||||
String message = error;
|
String message = error;
|
||||||
|
|
||||||
// Remove PlatformException wrapper if present
|
|
||||||
// Format: PlatformException(ERROR, actual message, null, null)
|
|
||||||
if (message.contains('PlatformException')) {
|
if (message.contains('PlatformException')) {
|
||||||
// Try to extract the actual error message
|
|
||||||
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
|
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
message = match.group(1)?.trim() ?? message;
|
message = match.group(1)?.trim() ?? message;
|
||||||
} else {
|
} else {
|
||||||
// Fallback: try simpler extraction
|
|
||||||
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
|
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
|
||||||
if (simpleMatch != null) {
|
if (simpleMatch != null) {
|
||||||
message = simpleMatch.group(1)?.trim() ?? message;
|
message = simpleMatch.group(1)?.trim() ?? message;
|
||||||
@@ -321,7 +307,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up any remaining artifacts
|
|
||||||
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
|
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
|
||||||
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
|
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
|
||||||
|
|
||||||
@@ -356,7 +341,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Extension icon
|
|
||||||
Container(
|
Container(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
@@ -390,7 +374,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Extension info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -415,7 +398,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Toggle switch
|
|
||||||
Switch(
|
Switch(
|
||||||
value: extension.enabled,
|
value: extension.enabled,
|
||||||
onChanged: hasError ? null : onToggle,
|
onChanged: hasError ? null : onToggle,
|
||||||
@@ -445,7 +427,6 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
|||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Check if any extension has download provider
|
|
||||||
final hasDownloadExtensions = extState.extensions
|
final hasDownloadExtensions = extState.extensions
|
||||||
.any((e) => e.enabled && e.hasDownloadProvider);
|
.any((e) => e.enabled && e.hasDownloadProvider);
|
||||||
|
|
||||||
@@ -514,7 +495,6 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
|||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Check if any extension has metadata provider
|
|
||||||
final hasMetadataExtensions = extState.extensions
|
final hasMetadataExtensions = extState.extensions
|
||||||
.any((e) => e.enabled && e.hasMetadataProvider);
|
.any((e) => e.enabled && e.hasMetadataProvider);
|
||||||
|
|
||||||
@@ -584,12 +564,10 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Get extensions with custom search
|
|
||||||
final searchProviders = extState.extensions
|
final searchProviders = extState.extensions
|
||||||
.where((e) => e.enabled && e.hasCustomSearch)
|
.where((e) => e.enabled && e.hasCustomSearch)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Get current provider name
|
|
||||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||||
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||||
@@ -689,7 +667,6 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Default option
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||||
title: Text(ctx.l10n.extensionDefaultProvider),
|
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||||
@@ -702,7 +679,6 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Extension options
|
|
||||||
...searchProviders.map((ext) => ListTile(
|
...searchProviders.map((ext) => ListTile(
|
||||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||||
title: Text(ext.displayName),
|
title: Text(ext.displayName),
|
||||||
|
|||||||
@@ -25,14 +25,12 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
LogBuffer().addListener(_onLogUpdate);
|
LogBuffer().addListener(_onLogUpdate);
|
||||||
// Start polling Go backend logs
|
|
||||||
LogBuffer().startGoLogPolling();
|
LogBuffer().startGoLogPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
LogBuffer().removeListener(_onLogUpdate);
|
LogBuffer().removeListener(_onLogUpdate);
|
||||||
// Stop polling when leaving screen
|
|
||||||
LogBuffer().stopGoLogPolling();
|
LogBuffer().stopGoLogPolling();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
@@ -131,7 +129,6 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button - same as other settings pages
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -208,14 +205,12 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Filter section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
|
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
// Level filter
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -269,7 +264,6 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
endIndent: 20,
|
endIndent: 20,
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
// Search field
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -314,7 +308,6 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Log entries section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
|
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
|
||||||
@@ -323,12 +316,10 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error summary card - shows detected issues
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _LogSummaryCard(logs: LogBuffer().entries),
|
child: _LogSummaryCard(logs: LogBuffer().entries),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Log list
|
|
||||||
logs.isEmpty
|
logs.isEmpty
|
||||||
? SliverToBoxAdapter(
|
? SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -379,7 +370,6 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom padding
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -418,7 +408,6 @@ class _LogEntryTile extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header: time, level, tag
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@@ -478,7 +467,6 @@ class _LogEntryTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
// Message
|
|
||||||
Text(
|
Text(
|
||||||
entry.message,
|
entry.message,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -488,7 +476,6 @@ class _LogEntryTile extends StatelessWidget {
|
|||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Error if present
|
|
||||||
if (entry.error != null) ...[
|
if (entry.error != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -526,10 +513,8 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Analyze logs for issues
|
|
||||||
final analysis = _analyzeLogs();
|
final analysis = _analyzeLogs();
|
||||||
|
|
||||||
// Don't show if no issues detected
|
|
||||||
if (!analysis.hasIssues) {
|
if (!analysis.hasIssues) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
@@ -547,7 +532,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -567,7 +551,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// ISP Blocking detected
|
|
||||||
if (analysis.hasISPBlocking) ...[
|
if (analysis.hasISPBlocking) ...[
|
||||||
_IssueBadge(
|
_IssueBadge(
|
||||||
icon: Icons.block,
|
icon: Icons.block,
|
||||||
@@ -580,7 +563,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
if (analysis.hasRateLimit) ...[
|
if (analysis.hasRateLimit) ...[
|
||||||
_IssueBadge(
|
_IssueBadge(
|
||||||
icon: Icons.speed,
|
icon: Icons.speed,
|
||||||
@@ -592,7 +574,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Network errors
|
|
||||||
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
|
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
|
||||||
_IssueBadge(
|
_IssueBadge(
|
||||||
icon: Icons.wifi_off,
|
icon: Icons.wifi_off,
|
||||||
@@ -604,7 +585,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Track not found
|
|
||||||
if (analysis.hasNotFound) ...[
|
if (analysis.hasNotFound) ...[
|
||||||
_IssueBadge(
|
_IssueBadge(
|
||||||
icon: Icons.search_off,
|
icon: Icons.search_off,
|
||||||
@@ -615,7 +595,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Error count
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Total errors: ${analysis.errorCount}',
|
'Total errors: ${analysis.errorCount}',
|
||||||
@@ -647,7 +626,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
final errorLower = (log.error ?? '').toLowerCase();
|
final errorLower = (log.error ?? '').toLowerCase();
|
||||||
final combined = '$msgLower $errorLower';
|
final combined = '$msgLower $errorLower';
|
||||||
|
|
||||||
// Check for ISP blocking (detected by Go backend)
|
|
||||||
if (combined.contains('isp blocking') ||
|
if (combined.contains('isp blocking') ||
|
||||||
combined.contains('isp may be') ||
|
combined.contains('isp may be') ||
|
||||||
combined.contains('blocked by isp') ||
|
combined.contains('blocked by isp') ||
|
||||||
@@ -655,21 +633,18 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
combined.contains('connection refused')) {
|
combined.contains('connection refused')) {
|
||||||
hasISPBlocking = true;
|
hasISPBlocking = true;
|
||||||
|
|
||||||
// Try to extract domain
|
|
||||||
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
||||||
if (domainMatch != null) {
|
if (domainMatch != null) {
|
||||||
blockedDomains.add(domainMatch.group(1)!);
|
blockedDomains.add(domainMatch.group(1)!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for rate limiting
|
|
||||||
if (combined.contains('rate limit') ||
|
if (combined.contains('rate limit') ||
|
||||||
combined.contains('429') ||
|
combined.contains('429') ||
|
||||||
combined.contains('too many requests')) {
|
combined.contains('too many requests')) {
|
||||||
hasRateLimit = true;
|
hasRateLimit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for network errors
|
|
||||||
if (combined.contains('connection') ||
|
if (combined.contains('connection') ||
|
||||||
combined.contains('timeout') ||
|
combined.contains('timeout') ||
|
||||||
combined.contains('network') ||
|
combined.contains('network') ||
|
||||||
@@ -677,7 +652,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
hasNetworkError = true;
|
hasNetworkError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for not found
|
|
||||||
if (combined.contains('not found') ||
|
if (combined.contains('not found') ||
|
||||||
combined.contains('no results') ||
|
combined.contains('no results') ||
|
||||||
combined.contains('could not find')) {
|
combined.contains('could not find')) {
|
||||||
|
|||||||
@@ -24,16 +24,13 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
|
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
|
||||||
|
|
||||||
// Use saved priority if available, otherwise use default order
|
|
||||||
if (extState.metadataProviderPriority.isNotEmpty) {
|
if (extState.metadataProviderPriority.isNotEmpty) {
|
||||||
_providers = List.from(extState.metadataProviderPriority);
|
_providers = List.from(extState.metadataProviderPriority);
|
||||||
// Add any new providers not in saved priority
|
|
||||||
for (final provider in allProviders) {
|
for (final provider in allProviders) {
|
||||||
if (!_providers.contains(provider)) {
|
if (!_providers.contains(provider)) {
|
||||||
_providers.add(provider);
|
_providers.add(provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove providers that no longer exist
|
|
||||||
_providers.removeWhere((p) => !allProviders.contains(p));
|
_providers.removeWhere((p) => !allProviders.contains(p));
|
||||||
} else {
|
} else {
|
||||||
_providers = allProviders;
|
_providers = allProviders;
|
||||||
@@ -57,7 +54,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -109,7 +105,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Description
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -122,7 +117,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Provider list
|
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
sliver: SliverReorderableList(
|
sliver: SliverReorderableList(
|
||||||
@@ -150,7 +144,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Info section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -258,7 +251,6 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Priority number
|
|
||||||
Container(
|
Container(
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -281,7 +273,6 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Provider icon
|
|
||||||
Icon(
|
Icon(
|
||||||
info.icon,
|
info.icon,
|
||||||
color: info.isBuiltIn
|
color: info.isBuiltIn
|
||||||
@@ -289,7 +280,6 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
: colorScheme.secondary,
|
: colorScheme.secondary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Provider name
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -309,7 +299,6 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Drag handle
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.drag_handle,
|
Icons.drag_handle,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -339,7 +328,6 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
// Extension provider
|
|
||||||
return _MetadataProviderInfo(
|
return _MetadataProviderInfo(
|
||||||
name: provider,
|
name: provider,
|
||||||
icon: Icons.extension,
|
icon: Icons.extension,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -63,7 +62,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Search Source section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
|
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
|
||||||
),
|
),
|
||||||
@@ -77,7 +75,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
.setMetadataSource(v),
|
.setMetadataSource(v),
|
||||||
),
|
),
|
||||||
if (settings.metadataSource == 'spotify') ...[
|
if (settings.metadataSource == 'spotify') ...[
|
||||||
// Info card about Spotify credentials requirement
|
|
||||||
if (settings.spotifyClientId.isEmpty)
|
if (settings.spotifyClientId.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -130,7 +127,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Download options section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
||||||
),
|
),
|
||||||
@@ -179,7 +175,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Performance section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
|
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
|
||||||
),
|
),
|
||||||
@@ -196,7 +191,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// App section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionApp),
|
child: SettingsSectionHeader(title: context.l10n.sectionApp),
|
||||||
),
|
),
|
||||||
@@ -230,7 +224,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Data section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionData),
|
child: SettingsSectionHeader(title: context.l10n.sectionData),
|
||||||
),
|
),
|
||||||
@@ -249,7 +242,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Debug section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
|
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
|
||||||
),
|
),
|
||||||
@@ -370,7 +362,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Client ID
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: clientIdController,
|
controller: clientIdController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -408,7 +399,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Client Secret
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: clientSecretController,
|
controller: clientSecretController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
@@ -804,7 +794,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
|
|
||||||
// Check if extension search provider is active AND enabled
|
|
||||||
Extension? activeExtension;
|
Extension? activeExtension;
|
||||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||||
activeExtension = extState.extensions
|
activeExtension = extState.extensions
|
||||||
@@ -846,10 +835,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
_SourceChip(
|
_SourceChip(
|
||||||
icon: Icons.graphic_eq,
|
icon: Icons.graphic_eq,
|
||||||
label: 'Deezer',
|
label: 'Deezer',
|
||||||
// Not selected if extension is active
|
|
||||||
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// If extension was active, reset it to default
|
|
||||||
if (hasExtensionSearch) {
|
if (hasExtensionSearch) {
|
||||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
}
|
}
|
||||||
@@ -860,10 +847,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
_SourceChip(
|
_SourceChip(
|
||||||
icon: Icons.music_note,
|
icon: Icons.music_note,
|
||||||
label: 'Spotify',
|
label: 'Spotify',
|
||||||
// Not selected if extension is active
|
|
||||||
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// If extension was active, reset it to default
|
|
||||||
if (hasExtensionSearch) {
|
if (hasExtensionSearch) {
|
||||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,17 +24,13 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
|
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
|
||||||
|
|
||||||
// Use saved priority if available, otherwise use default order
|
|
||||||
if (extState.providerPriority.isNotEmpty) {
|
if (extState.providerPriority.isNotEmpty) {
|
||||||
// Start with saved priority
|
|
||||||
_providers = List.from(extState.providerPriority);
|
_providers = List.from(extState.providerPriority);
|
||||||
// Add any new providers not in saved priority
|
|
||||||
for (final provider in allProviders) {
|
for (final provider in allProviders) {
|
||||||
if (!_providers.contains(provider)) {
|
if (!_providers.contains(provider)) {
|
||||||
_providers.add(provider);
|
_providers.add(provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove providers that no longer exist
|
|
||||||
_providers.removeWhere((p) => !allProviders.contains(p));
|
_providers.removeWhere((p) => !allProviders.contains(p));
|
||||||
} else {
|
} else {
|
||||||
_providers = allProviders;
|
_providers = allProviders;
|
||||||
@@ -58,7 +54,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -110,7 +105,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Description
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -123,7 +117,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Provider list
|
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
sliver: SliverReorderableList(
|
sliver: SliverReorderableList(
|
||||||
@@ -151,7 +144,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Info section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -246,7 +238,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: colorScheme.surfaceContainerHigh;
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
// Get provider info
|
|
||||||
final info = _getProviderInfo(provider);
|
final info = _getProviderInfo(provider);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -260,7 +251,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Priority number
|
|
||||||
Container(
|
Container(
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -283,7 +273,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Provider icon
|
|
||||||
Icon(
|
Icon(
|
||||||
info.icon,
|
info.icon,
|
||||||
color: info.isBuiltIn
|
color: info.isBuiltIn
|
||||||
@@ -291,7 +280,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
: colorScheme.secondary,
|
: colorScheme.secondary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Provider name
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -311,7 +299,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Drag handle
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.drag_handle,
|
Icons.drag_handle,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -345,7 +332,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
// Extension provider
|
|
||||||
return _ProviderInfo(
|
return _ProviderInfo(
|
||||||
name: provider,
|
name: provider,
|
||||||
icon: Icons.extension,
|
icon: Icons.extension,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -54,7 +53,6 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// First group: Appearance & Download
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
@@ -94,7 +92,6 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Second group: Logs & About
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
@@ -120,23 +117,18 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Fill remaining space
|
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateTo(BuildContext context, Widget page) {
|
void _navigateTo(BuildContext context, Widget page) {
|
||||||
// Unfocus any focused widget before navigating to prevent keyboard from appearing on return
|
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
// Use PageRouteBuilder for better predictive back gesture support
|
|
||||||
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
// Use slide transition similar to MaterialPageRoute
|
|
||||||
const begin = Offset(1.0, 0.0);
|
const begin = Offset(1.0, 0.0);
|
||||||
const end = Offset.zero;
|
const end = Offset.zero;
|
||||||
const curve = Curves.easeInOut;
|
const curve = Curves.easeInOut;
|
||||||
|
|||||||
@@ -25,13 +25,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
|
|
||||||
// Spotify API credentials
|
|
||||||
final _clientIdController = TextEditingController();
|
final _clientIdController = TextEditingController();
|
||||||
final _clientSecretController = TextEditingController();
|
final _clientSecretController = TextEditingController();
|
||||||
bool _useSpotifyApi = false;
|
bool _useSpotifyApi = false;
|
||||||
bool _showClientSecret = false;
|
bool _showClientSecret = false;
|
||||||
|
|
||||||
// Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
|
|
||||||
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -66,22 +64,18 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
// Check storage permission
|
|
||||||
bool storageGranted = false;
|
bool storageGranted = false;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
|
||||||
final manageStatus = await Permission.manageExternalStorage.status;
|
final manageStatus = await Permission.manageExternalStorage.status;
|
||||||
final audioStatus = await Permission.audio.status;
|
final audioStatus = await Permission.audio.status;
|
||||||
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
|
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
|
||||||
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
|
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
|
||||||
final manageStatus = await Permission.manageExternalStorage.status;
|
final manageStatus = await Permission.manageExternalStorage.status;
|
||||||
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
|
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
|
||||||
storageGranted = manageStatus.isGranted;
|
storageGranted = manageStatus.isGranted;
|
||||||
} else {
|
} else {
|
||||||
// Android 10 and below: Use legacy storage permission
|
|
||||||
final storageStatus = await Permission.storage.status;
|
final storageStatus = await Permission.storage.status;
|
||||||
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
|
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
|
||||||
storageGranted = storageStatus.isGranted;
|
storageGranted = storageStatus.isGranted;
|
||||||
@@ -89,7 +83,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
debugPrint('[Permission] Final storageGranted=$storageGranted');
|
debugPrint('[Permission] Final storageGranted=$storageGranted');
|
||||||
|
|
||||||
// Check notification permission (Android 13+)
|
|
||||||
PermissionStatus notificationStatus = PermissionStatus.granted;
|
PermissionStatus notificationStatus = PermissionStatus.granted;
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
notificationStatus = await Permission.notification.status;
|
notificationStatus = await Permission.notification.status;
|
||||||
@@ -115,9 +108,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
bool allGranted = false;
|
bool allGranted = false;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
|
||||||
|
|
||||||
// First check/request MANAGE_EXTERNAL_STORAGE
|
|
||||||
var manageStatus = await Permission.manageExternalStorage.status;
|
var manageStatus = await Permission.manageExternalStorage.status;
|
||||||
if (!manageStatus.isGranted) {
|
if (!manageStatus.isGranted) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -144,14 +134,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
if (shouldOpen == true) {
|
if (shouldOpen == true) {
|
||||||
await Permission.manageExternalStorage.request();
|
await Permission.manageExternalStorage.request();
|
||||||
// Re-check after returning from settings
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
manageStatus = await Permission.manageExternalStorage.status;
|
manageStatus = await Permission.manageExternalStorage.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then request READ_MEDIA_AUDIO (this shows a dialog)
|
|
||||||
var audioStatus = await Permission.audio.status;
|
var audioStatus = await Permission.audio.status;
|
||||||
if (!audioStatus.isGranted && manageStatus.isGranted) {
|
if (!audioStatus.isGranted && manageStatus.isGranted) {
|
||||||
audioStatus = await Permission.audio.request();
|
audioStatus = await Permission.audio.request();
|
||||||
@@ -160,7 +148,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
allGranted = manageStatus.isGranted && audioStatus.isGranted;
|
allGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||||
|
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
|
||||||
var manageStatus = await Permission.manageExternalStorage.status;
|
var manageStatus = await Permission.manageExternalStorage.status;
|
||||||
if (!manageStatus.isGranted) {
|
if (!manageStatus.isGranted) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -187,7 +174,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
if (shouldOpen == true) {
|
if (shouldOpen == true) {
|
||||||
await Permission.manageExternalStorage.request();
|
await Permission.manageExternalStorage.request();
|
||||||
// Re-check after returning from settings
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
manageStatus = await Permission.manageExternalStorage.status;
|
manageStatus = await Permission.manageExternalStorage.status;
|
||||||
}
|
}
|
||||||
@@ -196,7 +182,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
allGranted = manageStatus.isGranted;
|
allGranted = manageStatus.isGranted;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Android 10 and below: Use legacy storage permission
|
|
||||||
final status = await Permission.storage.request();
|
final status = await Permission.storage.request();
|
||||||
allGranted = status.isGranted;
|
allGranted = status.isGranted;
|
||||||
|
|
||||||
@@ -239,7 +224,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
_showPermissionDeniedDialog('Notification');
|
_showPermissionDeniedDialog('Notification');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Notification permission not needed for older Android
|
|
||||||
setState(() => _notificationPermissionGranted = true);
|
setState(() => _notificationPermissionGranted = true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -283,10 +267,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
// iOS: Show options dialog
|
|
||||||
await _showIOSDirectoryOptions();
|
await _showIOSDirectoryOptions();
|
||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
|
||||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||||
dialogTitle: context.l10n.setupSelectDownloadFolder,
|
dialogTitle: context.l10n.setupSelectDownloadFolder,
|
||||||
);
|
);
|
||||||
@@ -359,7 +341,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
// Note: iOS requires folder to have at least one file to be selectable
|
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
setState(() => _selectedDirectory = result);
|
setState(() => _selectedDirectory = result);
|
||||||
@@ -436,7 +417,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
||||||
|
|
||||||
// Save Spotify credentials if provided
|
|
||||||
if (_useSpotifyApi &&
|
if (_useSpotifyApi &&
|
||||||
_clientIdController.text.trim().isNotEmpty &&
|
_clientIdController.text.trim().isNotEmpty &&
|
||||||
_clientSecretController.text.trim().isNotEmpty) {
|
_clientSecretController.text.trim().isNotEmpty) {
|
||||||
@@ -444,10 +424,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
_clientIdController.text.trim(),
|
_clientIdController.text.trim(),
|
||||||
_clientSecretController.text.trim(),
|
_clientSecretController.text.trim(),
|
||||||
);
|
);
|
||||||
// Set search source to Spotify when credentials are provided
|
|
||||||
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||||
} else {
|
} else {
|
||||||
// Use Deezer as default search source (free, no credentials required)
|
|
||||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +460,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
// Top section - Logo/Title
|
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -501,7 +478,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Middle section - Steps and Content
|
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -511,7 +487,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom section - Navigation Buttons
|
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -596,15 +571,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
bool _isStepCompleted(int step) {
|
bool _isStepCompleted(int step) {
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// 4 steps: Storage, Notification, Folder, Spotify
|
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _notificationPermissionGranted;
|
case 1: return _notificationPermissionGranted;
|
||||||
case 2: return _selectedDirectory != null;
|
case 2: return _selectedDirectory != null;
|
||||||
case 3: return false; // Spotify step never shows checkmark (optional)
|
case 3: return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 3 steps: Permission, Folder, Spotify
|
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _selectedDirectory != null;
|
case 1: return _selectedDirectory != null;
|
||||||
@@ -637,7 +610,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Icon with container background (M3 style)
|
|
||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -691,7 +663,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Icon with container background (M3 style)
|
|
||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -754,7 +725,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Icon with container background (M3 style)
|
|
||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -829,7 +799,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Icon with container background (M3 style)
|
|
||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -860,7 +829,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Toggle card (M3 style)
|
|
||||||
Card(
|
Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.surfaceContainerHigh,
|
color: colorScheme.surfaceContainerHigh,
|
||||||
@@ -891,7 +859,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Credentials form (animated)
|
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
@@ -906,7 +873,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Client ID
|
|
||||||
Text(context.l10n.credentialsClientId, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
Text(context.l10n.credentialsClientId, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -925,7 +891,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Client Secret
|
|
||||||
Text(context.l10n.credentialsClientSecret, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
Text(context.l10n.credentialsClientSecret, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -949,7 +914,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Info banner
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -983,14 +947,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
final isLastStep = _currentStep == _totalSteps - 1;
|
final isLastStep = _currentStep == _totalSteps - 1;
|
||||||
final canProceed = _isStepCompleted(_currentStep);
|
final canProceed = _isStepCompleted(_currentStep);
|
||||||
|
|
||||||
// For Spotify step, check if credentials are valid when enabled
|
|
||||||
final isSpotifyStepValid = !_useSpotifyApi ||
|
final isSpotifyStepValid = !_useSpotifyApi ||
|
||||||
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
|
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
// Back button
|
|
||||||
if (_currentStep > 0)
|
if (_currentStep > 0)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
onPressed: () => setState(() => _currentStep--),
|
||||||
@@ -1003,7 +965,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
else
|
else
|
||||||
const SizedBox(width: 100),
|
const SizedBox(width: 100),
|
||||||
|
|
||||||
// Next/Finish button
|
|
||||||
if (!isLastStep)
|
if (!isLastStep)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
||||||
|
|||||||
@@ -20,11 +20,8 @@ class _ExtensionDetailsScreenState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Watch store provider to get latest state of this extension (e.g. if updated/installed)
|
|
||||||
final storeState = ref.watch(storeProvider);
|
final storeState = ref.watch(storeProvider);
|
||||||
|
|
||||||
// Find our extension in the store state to get the latest status
|
|
||||||
// If not found in current store state (rare), fallback to widget.extension
|
|
||||||
final liveExtension =
|
final liveExtension =
|
||||||
storeState.extensions
|
storeState.extensions
|
||||||
.where((e) => e.id == widget.extension.id)
|
.where((e) => e.id == widget.extension.id)
|
||||||
@@ -188,7 +185,6 @@ class _ExtensionDetailsScreenState
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Badges row
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@@ -215,7 +211,6 @@ class _ExtensionDetailsScreenState
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Action Buttons
|
|
||||||
if (isDownloading)
|
if (isDownloading)
|
||||||
Center(
|
Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
@@ -410,7 +405,6 @@ class _ExtensionDetailsScreenState
|
|||||||
StoreExtension ext,
|
StoreExtension ext,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
// Determine capabilities based on category
|
|
||||||
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
|
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
|
||||||
final isDownloadProvider = ext.category == 'download';
|
final isDownloadProvider = ext.category == 'download';
|
||||||
final isLyricsProvider = ext.category == 'lyrics';
|
final isLyricsProvider = ext.category == 'lyrics';
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
|
|
||||||
final cacheDir = await getApplicationCacheDirectory();
|
final cacheDir = await getApplicationCacheDirectory();
|
||||||
|
|
||||||
// Check if widget is still mounted after async operation
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
|
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
|
||||||
@@ -53,7 +52,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar - consistent with other tabs
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -87,7 +85,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Search Bar
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -131,7 +128,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Category Chips
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
@@ -203,7 +199,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Content
|
|
||||||
if (state.isLoading && state.extensions.isEmpty)
|
if (state.isLoading && state.extensions.isEmpty)
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
@@ -215,7 +210,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
else if (state.filteredExtensions.isEmpty)
|
else if (state.filteredExtensions.isEmpty)
|
||||||
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
|
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
|
||||||
else ...[
|
else ...[
|
||||||
// Extensions count
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -228,7 +222,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Extensions list in grouped card (like queue_tab)
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
@@ -252,7 +245,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom padding
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -457,7 +449,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Extension icon - custom or category-based
|
|
||||||
Container(
|
Container(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
@@ -507,7 +498,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Extension info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -521,7 +511,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
?.copyWith(fontWeight: FontWeight.w500),
|
?.copyWith(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Version badge
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 6,
|
horizontal: 6,
|
||||||
@@ -548,7 +537,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Warning badge for incompatible extensions
|
|
||||||
if (extension.requiresNewerApp) ...[
|
if (extension.requiresNewerApp) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
@@ -587,7 +575,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Action button
|
|
||||||
if (isDownloading)
|
if (isDownloading)
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkFile() async {
|
Future<void> _checkFile() async {
|
||||||
// Strip EXISTS: prefix from legacy history items
|
|
||||||
var filePath = widget.item.filePath;
|
var filePath = widget.item.filePath;
|
||||||
if (filePath.startsWith('EXISTS:')) {
|
if (filePath.startsWith('EXISTS:')) {
|
||||||
filePath = filePath.substring(7);
|
filePath = filePath.substring(7);
|
||||||
@@ -66,14 +65,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_fileSize = size;
|
_fileSize = size;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-load lyrics if file exists (embedded lyrics are instant)
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
_fetchLyrics();
|
_fetchLyrics();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use data directly from history item (cached from download)
|
|
||||||
DownloadHistoryItem get item => widget.item;
|
DownloadHistoryItem get item => widget.item;
|
||||||
String get trackName => item.trackName;
|
String get trackName => item.trackName;
|
||||||
String get artistName => item.artistName;
|
String get artistName => item.artistName;
|
||||||
@@ -84,7 +81,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
String? get releaseDate => item.releaseDate;
|
String? get releaseDate => item.releaseDate;
|
||||||
String? get isrc => item.isrc;
|
String? get isrc => item.isrc;
|
||||||
|
|
||||||
// Clean filePath - strip EXISTS: prefix from legacy history items
|
|
||||||
String get cleanFilePath {
|
String get cleanFilePath {
|
||||||
final path = item.filePath;
|
final path = item.filePath;
|
||||||
return path.startsWith('EXISTS:') ? path.substring(7) : path;
|
return path.startsWith('EXISTS:') ? path.substring(7) : path;
|
||||||
@@ -99,7 +95,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar with cover art background
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 280,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -138,34 +133,28 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Content
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Track info card
|
|
||||||
_buildTrackInfoCard(context, colorScheme, _fileExists),
|
_buildTrackInfoCard(context, colorScheme, _fileExists),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Metadata card
|
|
||||||
_buildMetadataCard(context, colorScheme, _fileSize),
|
_buildMetadataCard(context, colorScheme, _fileSize),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// File info card
|
|
||||||
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
|
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Lyrics card
|
|
||||||
_buildLyricsCard(context, colorScheme),
|
_buildLyricsCard(context, colorScheme),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
@@ -182,7 +171,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Blurred background
|
|
||||||
if (item.coverUrl != null)
|
if (item.coverUrl != null)
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: item.coverUrl!,
|
imageUrl: item.coverUrl!,
|
||||||
@@ -191,7 +179,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
colorBlendMode: BlendMode.darken,
|
colorBlendMode: BlendMode.darken,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Gradient overlay
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -207,7 +194,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Cover art centered
|
|
||||||
Center(
|
Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
padding: const EdgeInsets.only(top: 60),
|
||||||
@@ -268,7 +254,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Track name (from file metadata)
|
|
||||||
Text(
|
Text(
|
||||||
trackName,
|
trackName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
@@ -278,7 +263,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Artist name (from file metadata)
|
|
||||||
Text(
|
Text(
|
||||||
artistName,
|
artistName,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
@@ -287,7 +271,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Album name (from file metadata)
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -307,7 +290,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// File status
|
|
||||||
if (!fileExists) ...[
|
if (!fileExists) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
@@ -372,10 +354,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Metadata grid
|
|
||||||
_buildMetadataGrid(context, colorScheme),
|
_buildMetadataGrid(context, colorScheme),
|
||||||
|
|
||||||
// Streaming service link button
|
|
||||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Builder(
|
Builder(
|
||||||
@@ -416,28 +396,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
: Uri.parse('spotify:track:$rawId');
|
: Uri.parse('spotify:track:$rawId');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to open in App first using URI scheme
|
|
||||||
final launched = await launchUrl(
|
final launched = await launchUrl(
|
||||||
appUri,
|
appUri,
|
||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!launched) {
|
if (!launched) {
|
||||||
// Fallback to web URL which will redirect to app if installed
|
|
||||||
await launchUrl(
|
await launchUrl(
|
||||||
Uri.parse(webUrl),
|
Uri.parse(webUrl),
|
||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If URI scheme fails, try web URL
|
|
||||||
try {
|
try {
|
||||||
await launchUrl(
|
await launchUrl(
|
||||||
Uri.parse(webUrl),
|
Uri.parse(webUrl),
|
||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Last resort: copy to clipboard
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
_copyToClipboard(context, webUrl);
|
_copyToClipboard(context, webUrl);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -449,7 +425,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||||
// Build audio quality string from file metadata
|
|
||||||
String? audioQualityStr;
|
String? audioQualityStr;
|
||||||
if (bitDepth != null && sampleRate != null) {
|
if (bitDepth != null && sampleRate != null) {
|
||||||
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
||||||
@@ -568,7 +543,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Format chip
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@@ -651,7 +625,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// File path
|
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => _copyToClipboard(context, cleanFilePath),
|
onTap: () => _copyToClipboard(context, cleanFilePath),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -793,12 +766,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Convert duration from seconds to milliseconds
|
||||||
|
final durationMs = (item.duration ?? 0) * 1000;
|
||||||
|
|
||||||
// Add timeout to prevent infinite loading
|
// Add timeout to prevent infinite loading
|
||||||
final result = await PlatformBridge.getLyricsLRC(
|
final result = await PlatformBridge.getLyricsLRC(
|
||||||
item.spotifyId ?? '',
|
item.spotifyId ?? '',
|
||||||
item.trackName,
|
item.trackName,
|
||||||
item.artistName,
|
item.artistName,
|
||||||
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
|
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
|
||||||
|
durationMs: durationMs,
|
||||||
).timeout(
|
).timeout(
|
||||||
const Duration(seconds: 20),
|
const Duration(seconds: 20),
|
||||||
onTimeout: () => '', // Return empty string on timeout
|
onTimeout: () => '', // Return empty string on timeout
|
||||||
@@ -811,7 +788,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Clean up LRC timestamps for display
|
|
||||||
final cleanLyrics = _cleanLrcForDisplay(result);
|
final cleanLyrics = _cleanLrcForDisplay(result);
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyrics = cleanLyrics;
|
_lyrics = cleanLyrics;
|
||||||
@@ -833,7 +809,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _cleanLrcForDisplay(String lrc) {
|
String _cleanLrcForDisplay(String lrc) {
|
||||||
// Remove LRC timestamps [mm:ss.xx] for cleaner display
|
|
||||||
final lines = lrc.split('\n');
|
final lines = lrc.split('\n');
|
||||||
final cleanLines = <String>[];
|
final cleanLines = <String>[];
|
||||||
final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
||||||
@@ -851,7 +826,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) {
|
Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Play button
|
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
@@ -868,7 +842,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Delete button
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _confirmDelete(context, ref, colorScheme),
|
onPressed: () => _confirmDelete(context, ref, colorScheme),
|
||||||
@@ -951,7 +924,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
// Delete the file first
|
|
||||||
try {
|
try {
|
||||||
final file = File(cleanFilePath);
|
final file = File(cleanFilePath);
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
@@ -961,7 +933,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
debugPrint('Failed to delete file: $e');
|
debugPrint('Failed to delete file: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from history
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ class ApkDownloader {
|
|||||||
required String version,
|
required String version,
|
||||||
ProgressCallback? onProgress,
|
ProgressCallback? onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
// Validate URL for security
|
|
||||||
final uri = Uri.tryParse(url);
|
final uri = Uri.tryParse(url);
|
||||||
if (uri == null || uri.scheme != 'https') {
|
if (uri == null || uri.scheme != 'https') {
|
||||||
_log.e('Refusing to download from invalid or non-HTTPS URL');
|
_log.e('Refusing to download from invalid or non-HTTPS URL');
|
||||||
@@ -35,7 +34,6 @@ class ApkDownloader {
|
|||||||
|
|
||||||
final contentLength = response.contentLength ?? 0;
|
final contentLength = response.contentLength ?? 0;
|
||||||
|
|
||||||
// Get download directory
|
|
||||||
final dir = await getExternalStorageDirectory();
|
final dir = await getExternalStorageDirectory();
|
||||||
if (dir == null) {
|
if (dir == null) {
|
||||||
_log.e('Could not get storage directory');
|
_log.e('Could not get storage directory');
|
||||||
@@ -45,7 +43,6 @@ class ApkDownloader {
|
|||||||
final filePath = '${dir.path}/SpotiFLAC-$version.apk';
|
final filePath = '${dir.path}/SpotiFLAC-$version.apk';
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
|
|
||||||
// Delete if exists
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
await file.delete();
|
await file.delete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class CsvImportService {
|
|||||||
final content = await file.readAsString();
|
final content = await file.readAsString();
|
||||||
final tracks = _parseCsv(content);
|
final tracks = _parseCsv(content);
|
||||||
|
|
||||||
// Enrich tracks with metadata from Deezer (cover URL, duration, etc.)
|
|
||||||
if (tracks.isNotEmpty) {
|
if (tracks.isNotEmpty) {
|
||||||
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
|
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
|
||||||
}
|
}
|
||||||
@@ -48,11 +47,9 @@ class CsvImportService {
|
|||||||
final track = tracks[i];
|
final track = tracks[i];
|
||||||
onProgress?.call(i + 1, tracks.length);
|
onProgress?.call(i + 1, tracks.length);
|
||||||
|
|
||||||
// Only enrich if missing cover/duration
|
|
||||||
if (track.coverUrl == null || track.duration == 0) {
|
if (track.coverUrl == null || track.duration == 0) {
|
||||||
Map<String, dynamic>? trackData;
|
Map<String, dynamic>? trackData;
|
||||||
|
|
||||||
// Try ISRC first if available
|
|
||||||
if (track.isrc != null && track.isrc!.isNotEmpty) {
|
if (track.isrc != null && track.isrc!.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
|
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
|
||||||
@@ -62,7 +59,6 @@ class CsvImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to text search if ISRC failed or not available
|
|
||||||
if (trackData == null) {
|
if (trackData == null) {
|
||||||
try {
|
try {
|
||||||
final query = '${track.artistName} ${track.name}';
|
final query = '${track.artistName} ${track.name}';
|
||||||
@@ -71,13 +67,11 @@ class CsvImportService {
|
|||||||
if (searchResult.containsKey('tracks')) {
|
if (searchResult.containsKey('tracks')) {
|
||||||
final tracksList = searchResult['tracks'] as List<dynamic>?;
|
final tracksList = searchResult['tracks'] as List<dynamic>?;
|
||||||
if (tracksList != null && tracksList.isNotEmpty) {
|
if (tracksList != null && tracksList.isNotEmpty) {
|
||||||
// Find best match by comparing names
|
|
||||||
for (final result in tracksList) {
|
for (final result in tracksList) {
|
||||||
final resultMap = result as Map<String, dynamic>;
|
final resultMap = result as Map<String, dynamic>;
|
||||||
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? '';
|
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? '';
|
||||||
final trackNameLower = track.name.toLowerCase();
|
final trackNameLower = track.name.toLowerCase();
|
||||||
|
|
||||||
// Check if track name matches (contains or equals)
|
|
||||||
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
|
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
|
||||||
trackData = resultMap;
|
trackData = resultMap;
|
||||||
_log.d('Text search match for ${track.name}: $resultName');
|
_log.d('Text search match for ${track.name}: $resultName');
|
||||||
@@ -85,7 +79,6 @@ class CsvImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no exact match, use first result
|
|
||||||
if (trackData == null && tracksList.isNotEmpty) {
|
if (trackData == null && tracksList.isNotEmpty) {
|
||||||
trackData = tracksList.first as Map<String, dynamic>;
|
trackData = tracksList.first as Map<String, dynamic>;
|
||||||
_log.d('Using first search result for ${track.name}');
|
_log.d('Using first search result for ${track.name}');
|
||||||
@@ -97,7 +90,6 @@ class CsvImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply enriched data if found
|
|
||||||
if (trackData != null) {
|
if (trackData != null) {
|
||||||
final coverUrl = trackData['images'] as String?;
|
final coverUrl = trackData['images'] as String?;
|
||||||
final durationMs = trackData['duration_ms'] as int? ?? 0;
|
final durationMs = trackData['duration_ms'] as int? ?? 0;
|
||||||
@@ -119,7 +111,6 @@ class CsvImportService {
|
|||||||
|
|
||||||
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
|
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
|
||||||
|
|
||||||
// Small delay to avoid rate limiting
|
|
||||||
if (i < tracks.length - 1) {
|
if (i < tracks.length - 1) {
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
}
|
}
|
||||||
@@ -127,7 +118,6 @@ class CsvImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep original track if enrichment failed or not needed
|
|
||||||
enrichedTracks.add(track);
|
enrichedTracks.add(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +127,9 @@ class CsvImportService {
|
|||||||
|
|
||||||
static List<Track> _parseCsv(String content) {
|
static List<Track> _parseCsv(String content) {
|
||||||
final List<Track> tracks = [];
|
final List<Track> tracks = [];
|
||||||
final lines = content.split(RegExp(r'\r\n|\r|\n')); // Handle various newline formats
|
final lines = content.split(RegExp(r'\r\n|\r|\n'));
|
||||||
if (lines.isEmpty) return tracks;
|
if (lines.isEmpty) return tracks;
|
||||||
|
|
||||||
// Detect headers line (assume first non-empty line)
|
|
||||||
int startIdx = 0;
|
int startIdx = 0;
|
||||||
while (startIdx < lines.length && lines[startIdx].trim().isEmpty) {
|
while (startIdx < lines.length && lines[startIdx].trim().isEmpty) {
|
||||||
startIdx++;
|
startIdx++;
|
||||||
@@ -150,37 +139,32 @@ class CsvImportService {
|
|||||||
final headers = _parseLine(lines[startIdx]);
|
final headers = _parseLine(lines[startIdx]);
|
||||||
final colMap = <String, int>{};
|
final colMap = <String, int>{};
|
||||||
for (int i = 0; i < headers.length; i++) {
|
for (int i = 0; i < headers.length; i++) {
|
||||||
// Normalize header: lowercase, trim, remove quotes
|
|
||||||
String h = _cleanValue(headers[i]).toLowerCase();
|
String h = _cleanValue(headers[i]).toLowerCase();
|
||||||
colMap[h] = i;
|
colMap[h] = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.d('CSV Headers: ${colMap.keys.toList()}');
|
_log.d('CSV Headers: ${colMap.keys.toList()}');
|
||||||
|
|
||||||
// Parse rows
|
|
||||||
for (int i = startIdx + 1; i < lines.length; i++) {
|
for (int i = startIdx + 1; i < lines.length; i++) {
|
||||||
final line = lines[i].trim();
|
final line = lines[i].trim();
|
||||||
if (line.isEmpty) continue;
|
if (line.isEmpty) continue;
|
||||||
|
|
||||||
final values = _parseLine(line);
|
final values = _parseLine(line);
|
||||||
|
|
||||||
// Helper to get value securely
|
|
||||||
String? getVal(List<String> keys) {
|
String? getVal(List<String> keys) {
|
||||||
return _getValue(values, colMap, keys);
|
return _getValue(values, colMap, keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? trackName = getVal(['track name', 'track', 'name', 'title']);
|
String? trackName = getVal(['track name', 'track', 'name', 'title']);
|
||||||
String? artistName = getVal(['artist name', 'artist']);
|
String? artistName = getVal(['artist name(s)', 'artist name', 'artist', 'artists']);
|
||||||
String? albumName = getVal(['album name', 'album']);
|
String? albumName = getVal(['album name', 'album']);
|
||||||
String? isrc = getVal(['isrc']); // Often formatted with leading/trailing quotes
|
String? isrc = getVal(['isrc']);
|
||||||
String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']); // Uri might need parsing
|
String? spotifyId = getVal(['track uri', 'spotify - id', 'spotify id', 'spotify_id', 'id', 'uri']);
|
||||||
|
|
||||||
// If 'spotify uri' contains the id: 'spotify:track:ID'
|
|
||||||
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
|
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
|
||||||
spotifyId = spotifyId.replaceAll('spotify:track:', '');
|
spotifyId = spotifyId.replaceAll('spotify:track:', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic validation: Need at least name and artist, OR a spotify ID
|
|
||||||
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
|
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
|
||||||
tracks.add(Track(
|
tracks.add(Track(
|
||||||
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
|
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
|
||||||
@@ -215,28 +199,21 @@ class CsvImportService {
|
|||||||
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
||||||
val = val.substring(1, val.length - 1);
|
val = val.substring(1, val.length - 1);
|
||||||
}
|
}
|
||||||
// Handle double quotes escape in CSV ("" -> ")
|
|
||||||
val = val.replaceAll('""', '"');
|
val = val.replaceAll('""', '"');
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Robust CSV Line Parser
|
|
||||||
static List<String> _parseLine(String line) {
|
static List<String> _parseLine(String line) {
|
||||||
final List<String> result = [];
|
final List<String> result = [];
|
||||||
bool inQuote = false;
|
bool inQuote = false;
|
||||||
StringBuffer buffer = StringBuffer();
|
StringBuffer buffer = StringBuffer();
|
||||||
|
|
||||||
for (int i=0; i<line.length; i++) {
|
for (int i=0; i<line.length; i++) {
|
||||||
String char = line[i];
|
String char = line[i];
|
||||||
if (char == '"') {
|
if (char == '"') {
|
||||||
// Look ahead to check for escaped quote
|
if (i + 1 < line.length && line[i+1] == '"') {
|
||||||
if (i + 1 < line.length && line[i+1] == '"') {
|
buffer.write('"');
|
||||||
buffer.write('"'); // Keep format for now, _cleanValue handles unescaping logic differently...
|
buffer.write('"');
|
||||||
// Wait, standard CSV: "Thumb ""Up""" -> Thumb "Up"
|
|
||||||
// My _cleanValue handles it, so I should just preserve raw content here mostly,
|
|
||||||
// BUT I need to know if " toggles inQuote.
|
|
||||||
// Escaped "" does NOT toggle inQuote mode effectively (it counts as literal char inside quote).
|
|
||||||
buffer.write('"'); // Write 1st quote
|
|
||||||
i++; // Skip next quote char loop
|
i++; // Skip next quote char loop
|
||||||
buffer.write('"'); // Write 2nd quote
|
buffer.write('"'); // Write 2nd quote
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -31,14 +31,12 @@ class FFmpegService {
|
|||||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||||
|
|
||||||
// FFmpeg command to remux M4A to FLAC
|
|
||||||
final command =
|
final command =
|
||||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
|
||||||
final result = await _execute(command);
|
final result = await _execute(command);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Delete original M4A file
|
|
||||||
try {
|
try {
|
||||||
await File(inputPath).delete();
|
await File(inputPath).delete();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -59,7 +57,6 @@ class FFmpegService {
|
|||||||
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
||||||
|
|
||||||
// Create output directory
|
|
||||||
await Directory(outputDir).create(recursive: true);
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
|
||||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
||||||
@@ -88,18 +85,15 @@ class FFmpegService {
|
|||||||
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||||
|
|
||||||
// Create output directory
|
|
||||||
await Directory(outputDir).create(recursive: true);
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
|
||||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
||||||
|
|
||||||
String command;
|
String command;
|
||||||
if (codec == 'alac') {
|
if (codec == 'alac') {
|
||||||
// ALAC - lossless
|
|
||||||
command =
|
command =
|
||||||
'-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
'-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
} else {
|
} else {
|
||||||
// AAC - lossy
|
|
||||||
command =
|
command =
|
||||||
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
}
|
}
|
||||||
@@ -141,25 +135,19 @@ class FFmpegService {
|
|||||||
String? coverPath,
|
String? coverPath,
|
||||||
Map<String, String>? metadata,
|
Map<String, String>? metadata,
|
||||||
}) async {
|
}) async {
|
||||||
// Android Scoped Storage: Cannot write directly to Music folder with FFmpeg
|
|
||||||
// Use app-internal cache directory for temp output
|
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
|
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
|
||||||
|
|
||||||
// Construct command
|
|
||||||
final StringBuffer cmdBuffer = StringBuffer();
|
final StringBuffer cmdBuffer = StringBuffer();
|
||||||
cmdBuffer.write('-i "$flacPath" ');
|
cmdBuffer.write('-i "$flacPath" ');
|
||||||
|
|
||||||
// Add cover input if available
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
cmdBuffer.write('-i "$coverPath" ');
|
cmdBuffer.write('-i "$coverPath" ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map audio stream
|
|
||||||
cmdBuffer.write('-map 0:a ');
|
cmdBuffer.write('-map 0:a ');
|
||||||
|
|
||||||
// Map cover stream if available
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
cmdBuffer.write('-map 1:0 ');
|
cmdBuffer.write('-map 1:0 ');
|
||||||
cmdBuffer.write('-c:v copy ');
|
cmdBuffer.write('-c:v copy ');
|
||||||
@@ -168,13 +156,10 @@ class FFmpegService {
|
|||||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy audio codec (don't re-encode)
|
|
||||||
cmdBuffer.write('-c:a copy ');
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
// Add text metadata
|
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
metadata.forEach((key, value) {
|
metadata.forEach((key, value) {
|
||||||
// Sanitize value: escape double quotes
|
|
||||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||||
});
|
});
|
||||||
@@ -189,18 +174,14 @@ class FFmpegService {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
try {
|
try {
|
||||||
// Copy temp output back to original location (replace)
|
|
||||||
final tempFile = File(tempOutput);
|
final tempFile = File(tempOutput);
|
||||||
final originalFile = File(flacPath);
|
final originalFile = File(flacPath);
|
||||||
|
|
||||||
if (await tempFile.exists()) {
|
if (await tempFile.exists()) {
|
||||||
// Delete original file
|
|
||||||
if (await originalFile.exists()) {
|
if (await originalFile.exists()) {
|
||||||
await originalFile.delete();
|
await originalFile.delete();
|
||||||
}
|
}
|
||||||
// Copy temp file to original location
|
|
||||||
await tempFile.copy(flacPath);
|
await tempFile.copy(flacPath);
|
||||||
// Delete temp file
|
|
||||||
await tempFile.delete();
|
await tempFile.delete();
|
||||||
|
|
||||||
return flacPath;
|
return flacPath;
|
||||||
@@ -215,7 +196,6 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up temp file if exists
|
|
||||||
try {
|
try {
|
||||||
final tempFile = File(tempOutput);
|
final tempFile = File(tempOutput);
|
||||||
if (await tempFile.exists()) {
|
if (await tempFile.exists()) {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class NotificationService {
|
|||||||
|
|
||||||
await _notifications.initialize(initSettings);
|
await _notifications.initialize(initSettings);
|
||||||
|
|
||||||
// Create notification channel for Android
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
await _notifications
|
await _notifications
|
||||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||||
@@ -227,7 +226,6 @@ class NotificationService {
|
|||||||
await _notifications.cancel(downloadProgressId);
|
await _notifications.cancel(downloadProgressId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update APK download notifications
|
|
||||||
Future<void> showUpdateDownloadProgress({
|
Future<void> showUpdateDownloadProgress({
|
||||||
required String version,
|
required String version,
|
||||||
required int received,
|
required int received,
|
||||||
|
|||||||
@@ -236,32 +236,38 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch lyrics for a track
|
/// Fetch lyrics for a track
|
||||||
|
/// [durationMs] is the track duration in milliseconds for better matching
|
||||||
static Future<Map<String, dynamic>> fetchLyrics(
|
static Future<Map<String, dynamic>> fetchLyrics(
|
||||||
String spotifyId,
|
String spotifyId,
|
||||||
String trackName,
|
String trackName,
|
||||||
String artistName,
|
String artistName, {
|
||||||
) async {
|
int durationMs = 0,
|
||||||
|
}) async {
|
||||||
final result = await _channel.invokeMethod('fetchLyrics', {
|
final result = await _channel.invokeMethod('fetchLyrics', {
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
'track_name': trackName,
|
'track_name': trackName,
|
||||||
'artist_name': artistName,
|
'artist_name': artistName,
|
||||||
|
'duration_ms': durationMs,
|
||||||
});
|
});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get lyrics in LRC format
|
/// Get lyrics in LRC format
|
||||||
/// First tries to extract from embedded file, then falls back to internet
|
/// First tries to extract from embedded file, then falls back to internet
|
||||||
|
/// [durationMs] is the track duration in milliseconds for better matching
|
||||||
static Future<String> getLyricsLRC(
|
static Future<String> getLyricsLRC(
|
||||||
String spotifyId,
|
String spotifyId,
|
||||||
String trackName,
|
String trackName,
|
||||||
String artistName, {
|
String artistName, {
|
||||||
String? filePath,
|
String? filePath,
|
||||||
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final result = await _channel.invokeMethod('getLyricsLRC', {
|
final result = await _channel.invokeMethod('getLyricsLRC', {
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
'track_name': trackName,
|
'track_name': trackName,
|
||||||
'artist_name': artistName,
|
'artist_name': artistName,
|
||||||
'file_path': filePath ?? '',
|
'file_path': filePath ?? '',
|
||||||
|
'duration_ms': durationMs,
|
||||||
});
|
});
|
||||||
return result as String;
|
return result as String;
|
||||||
}
|
}
|
||||||
@@ -770,7 +776,6 @@ class PlatformBridge {
|
|||||||
if (result == null || result == '') return null;
|
if (result == null || result == '') return null;
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// No extension found or error handling URL
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,31 +30,26 @@ class ShareIntentService {
|
|||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
|
|
||||||
// Listen to media sharing coming from outside the app while the app is in memory
|
|
||||||
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
|
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
|
||||||
_handleSharedMedia,
|
_handleSharedMedia,
|
||||||
onError: (err) => _log.e('Error: $err'),
|
onError: (err) => _log.e('Error: $err'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the media sharing coming from outside the app while the app is closed
|
|
||||||
final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia();
|
final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia();
|
||||||
if (initialMedia.isNotEmpty) {
|
if (initialMedia.isNotEmpty) {
|
||||||
_handleSharedMedia(initialMedia, isInitial: true);
|
_handleSharedMedia(initialMedia, isInitial: true);
|
||||||
// Tell the library that we are done processing the intent
|
|
||||||
ReceiveSharingIntent.instance.reset();
|
ReceiveSharingIntent.instance.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSharedMedia(List<SharedMediaFile> files, {bool isInitial = false}) {
|
void _handleSharedMedia(List<SharedMediaFile> files, {bool isInitial = false}) {
|
||||||
for (final file in files) {
|
for (final file in files) {
|
||||||
// Check the path - for text shares, the path contains the shared text
|
|
||||||
final textToCheck = file.path;
|
final textToCheck = file.path;
|
||||||
|
|
||||||
final url = _extractSpotifyUrl(textToCheck);
|
final url = _extractSpotifyUrl(textToCheck);
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
_log.i('Received Spotify URL: $url (initial: $isInitial)');
|
_log.i('Received Spotify URL: $url (initial: $isInitial)');
|
||||||
if (isInitial) {
|
if (isInitial) {
|
||||||
// Store for later - listener might not be ready yet
|
|
||||||
_pendingUrl = url;
|
_pendingUrl = url;
|
||||||
}
|
}
|
||||||
_sharedUrlController.add(url);
|
_sharedUrlController.add(url);
|
||||||
@@ -71,18 +66,15 @@ class ShareIntentService {
|
|||||||
String? _extractSpotifyUrl(String text) {
|
String? _extractSpotifyUrl(String text) {
|
||||||
if (text.isEmpty) return null;
|
if (text.isEmpty) return null;
|
||||||
|
|
||||||
// Check for spotify: URI format
|
|
||||||
final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text);
|
final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text);
|
||||||
if (uriMatch != null) {
|
if (uriMatch != null) {
|
||||||
return uriMatch.group(0);
|
return uriMatch.group(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for open.spotify.com URL
|
|
||||||
final urlMatch = RegExp(
|
final urlMatch = RegExp(
|
||||||
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
||||||
).firstMatch(text);
|
).firstMatch(text);
|
||||||
if (urlMatch != null) {
|
if (urlMatch != null) {
|
||||||
// Return URL without query params for cleaner handling
|
|
||||||
final fullUrl = urlMatch.group(0)!;
|
final fullUrl = urlMatch.group(0)!;
|
||||||
final queryIndex = fullUrl.indexOf('?');
|
final queryIndex = fullUrl.indexOf('?');
|
||||||
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
|
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ class UpdateChecker {
|
|||||||
Map<String, dynamic>? releaseData;
|
Map<String, dynamic>? releaseData;
|
||||||
|
|
||||||
if (channel == 'preview') {
|
if (channel == 'preview') {
|
||||||
// For preview channel, get all releases and find the latest (including prereleases)
|
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
Uri.parse('$_allReleasesApiUrl?per_page=10'),
|
Uri.parse('$_allReleasesApiUrl?per_page=10'),
|
||||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||||
@@ -82,10 +81,8 @@ class UpdateChecker {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First release is the latest (including prereleases)
|
|
||||||
releaseData = releases.first as Map<String, dynamic>;
|
releaseData = releases.first as Map<String, dynamic>;
|
||||||
} else {
|
} else {
|
||||||
// For stable channel, use /latest endpoint (excludes prereleases)
|
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
Uri.parse(_latestApiUrl),
|
Uri.parse(_latestApiUrl),
|
||||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||||
@@ -124,7 +121,6 @@ class UpdateChecker {
|
|||||||
final name = (asset['name'] as String? ?? '').toLowerCase();
|
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||||
if (name.endsWith('.apk')) {
|
if (name.endsWith('.apk')) {
|
||||||
final downloadUrl = asset['browser_download_url'] as String?;
|
final downloadUrl = asset['browser_download_url'] as String?;
|
||||||
// Only accept HTTPS URLs for security
|
|
||||||
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
|
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
|
||||||
if (uri == null || uri.scheme != 'https') {
|
if (uri == null || uri.scheme != 'https') {
|
||||||
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
|
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
|||||||
|
|
||||||
return DynamicColorBuilder(
|
return DynamicColorBuilder(
|
||||||
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
||||||
// Determine which color scheme to use
|
|
||||||
ColorScheme lightScheme;
|
ColorScheme lightScheme;
|
||||||
ColorScheme darkScheme;
|
ColorScheme darkScheme;
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
|||||||
lightScheme = lightDynamic;
|
lightScheme = lightDynamic;
|
||||||
darkScheme = darkDynamic;
|
darkScheme = darkDynamic;
|
||||||
} else {
|
} else {
|
||||||
// Fallback to seed color
|
|
||||||
final seedColor = themeSettings.seedColor;
|
final seedColor = themeSettings.seedColor;
|
||||||
lightScheme = ColorScheme.fromSeed(
|
lightScheme = ColorScheme.fromSeed(
|
||||||
seedColor: seedColor,
|
seedColor: seedColor,
|
||||||
@@ -45,7 +43,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
|||||||
darkScheme = _applyAmoledColors(darkScheme);
|
darkScheme = _applyAmoledColors(darkScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build themes
|
|
||||||
final lightTheme = AppTheme.light(dynamicScheme: lightScheme);
|
final lightTheme = AppTheme.light(dynamicScheme: lightScheme);
|
||||||
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled);
|
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled);
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
static bool get loggingEnabled => _loggingEnabled;
|
static bool get loggingEnabled => _loggingEnabled;
|
||||||
static set loggingEnabled(bool value) {
|
static set loggingEnabled(bool value) {
|
||||||
_loggingEnabled = value;
|
_loggingEnabled = value;
|
||||||
// Also notify Go backend about logging state
|
|
||||||
if (value) {
|
if (value) {
|
||||||
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
|
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
|
||||||
} else {
|
} else {
|
||||||
@@ -121,7 +120,6 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Use current time if parsing fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +144,6 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
void clear() {
|
void clear() {
|
||||||
_entries.clear();
|
_entries.clear();
|
||||||
_lastGoLogIndex = 0;
|
_lastGoLogIndex = 0;
|
||||||
// Also clear Go backend logs
|
|
||||||
PlatformBridge.clearGoLogs().catchError((_) {});
|
PlatformBridge.clearGoLogs().catchError((_) {});
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -191,14 +188,12 @@ class BufferedOutput extends LogOutput {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void output(OutputEvent event) {
|
void output(OutputEvent event) {
|
||||||
// Print to console in debug mode
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
for (final line in event.lines) {
|
for (final line in event.lines) {
|
||||||
debugPrint(line);
|
debugPrint(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to buffer
|
|
||||||
final level = _levelToString(event.level);
|
final level = _levelToString(event.level);
|
||||||
final message = event.lines.join('\n');
|
final message = event.lines.join('\n');
|
||||||
|
|
||||||
@@ -249,8 +244,6 @@ class AppLogger {
|
|||||||
late final Logger? _logger;
|
late final Logger? _logger;
|
||||||
|
|
||||||
AppLogger(this._tag) {
|
AppLogger(this._tag) {
|
||||||
// Only create Logger instance in debug mode
|
|
||||||
// In release mode, we write directly to LogBuffer
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
_logger = Logger(
|
_logger = Logger(
|
||||||
printer: SimplePrinter(printTime: false, colors: false),
|
printer: SimplePrinter(printTime: false, colors: false),
|
||||||
@@ -276,7 +269,6 @@ class AppLogger {
|
|||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
_logger?.d(message);
|
_logger?.d(message);
|
||||||
} else {
|
} else {
|
||||||
// In release mode, write directly to buffer
|
|
||||||
_addToBuffer('DEBUG', message);
|
_addToBuffer('DEBUG', message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ class CollapsingHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Info card if provided
|
|
||||||
if (infoCard != null)
|
if (infoCard != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -73,7 +72,6 @@ class CollapsingHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Content slivers
|
|
||||||
...slivers,
|
...slivers,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -105,20 +105,17 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
|
|
||||||
/// Get quality options for the selected service
|
/// Get quality options for the selected service
|
||||||
List<QualityOption> _getQualityOptions() {
|
List<QualityOption> _getQualityOptions() {
|
||||||
// Check if it's a built-in service
|
|
||||||
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
|
||||||
if (builtIn != null) {
|
if (builtIn != null) {
|
||||||
return builtIn.qualityOptions;
|
return builtIn.qualityOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's an extension
|
|
||||||
final extensionState = ref.read(extensionProvider);
|
final extensionState = ref.read(extensionProvider);
|
||||||
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
|
||||||
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
if (ext != null && ext.qualityOptions.isNotEmpty) {
|
||||||
return ext.qualityOptions;
|
return ext.qualityOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default quality options if extension doesn't specify any
|
|
||||||
return const [
|
return const [
|
||||||
QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
|
||||||
];
|
];
|
||||||
@@ -129,7 +126,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final extensionState = ref.watch(extensionProvider);
|
final extensionState = ref.watch(extensionProvider);
|
||||||
|
|
||||||
// Get enabled download provider extensions
|
|
||||||
final downloadExtensions = extensionState.extensions
|
final downloadExtensions = extensionState.extensions
|
||||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -142,7 +138,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Track info header (if provided)
|
|
||||||
if (widget.trackName != null) ...[
|
if (widget.trackName != null) ...[
|
||||||
_TrackInfoHeader(
|
_TrackInfoHeader(
|
||||||
trackName: widget.trackName!,
|
trackName: widget.trackName!,
|
||||||
@@ -164,7 +159,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Service selector section
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -173,21 +167,18 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Built-in services
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
// Built-in services
|
|
||||||
for (final service in _builtInServices)
|
for (final service in _builtInServices)
|
||||||
_ServiceChip(
|
_ServiceChip(
|
||||||
label: service.label,
|
label: service.label,
|
||||||
isSelected: _selectedService == service.id,
|
isSelected: _selectedService == service.id,
|
||||||
onTap: () => setState(() => _selectedService = service.id),
|
onTap: () => setState(() => _selectedService = service.id),
|
||||||
),
|
),
|
||||||
// Extension services
|
|
||||||
for (final ext in downloadExtensions)
|
for (final ext in downloadExtensions)
|
||||||
_ServiceChip(
|
_ServiceChip(
|
||||||
label: ext.displayName,
|
label: ext.displayName,
|
||||||
@@ -199,7 +190,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Quality selector section
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -208,7 +198,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Disclaimer for built-in services
|
|
||||||
if (_builtInServices.any((s) => s.id == _selectedService))
|
if (_builtInServices.any((s) => s.id == _selectedService))
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
@@ -221,7 +210,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Quality options
|
|
||||||
for (final quality in qualityOptions)
|
for (final quality in qualityOptions)
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: quality.label,
|
title: quality.label,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
Future<void> _downloadAndInstall() async {
|
Future<void> _downloadAndInstall() async {
|
||||||
final apkUrl = widget.updateInfo.apkDownloadUrl;
|
final apkUrl = widget.updateInfo.apkDownloadUrl;
|
||||||
|
|
||||||
// If no direct APK URL, open release page
|
|
||||||
if (apkUrl == null) {
|
if (apkUrl == null) {
|
||||||
final uri = Uri.parse(widget.updateInfo.downloadUrl);
|
final uri = Uri.parse(widget.updateInfo.downloadUrl);
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
@@ -60,7 +59,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
_statusText = '$receivedMB / $totalMB MB';
|
_statusText = '$receivedMB / $totalMB MB';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Update notification
|
|
||||||
notificationService.showUpdateDownloadProgress(
|
notificationService.showUpdateDownloadProgress(
|
||||||
version: widget.updateInfo.version,
|
version: widget.updateInfo.version,
|
||||||
received: received,
|
received: received,
|
||||||
@@ -70,7 +68,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
// Cancel progress notification first
|
|
||||||
await notificationService.cancelUpdateNotification();
|
await notificationService.cancelUpdateNotification();
|
||||||
|
|
||||||
await notificationService.showUpdateDownloadComplete(
|
await notificationService.showUpdateDownloadComplete(
|
||||||
@@ -81,10 +78,8 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open APK for installation
|
|
||||||
await ApkDownloader.installApk(filePath);
|
await ApkDownloader.installApk(filePath);
|
||||||
} else {
|
} else {
|
||||||
// Cancel progress notification first
|
|
||||||
await notificationService.cancelUpdateNotification();
|
await notificationService.cancelUpdateNotification();
|
||||||
|
|
||||||
await notificationService.showUpdateDownloadFailed();
|
await notificationService.showUpdateDownloadFailed();
|
||||||
@@ -116,7 +111,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header with icon
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -142,7 +136,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Version badge
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -165,7 +158,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Download progress (when downloading)
|
|
||||||
if (_isDownloading) ...[
|
if (_isDownloading) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -209,7 +201,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
// Changelog section
|
|
||||||
Text(context.l10n.updateWhatsNew, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
|
Text(context.l10n.updateWhatsNew, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
@@ -231,7 +222,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
],
|
],
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
if (_isDownloading)
|
if (_isDownloading)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -303,19 +293,16 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
String _formatChangelog(String changelog) {
|
String _formatChangelog(String changelog) {
|
||||||
var content = changelog;
|
var content = changelog;
|
||||||
|
|
||||||
// Find content after "What's New" header
|
|
||||||
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
|
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
|
||||||
if (whatsNewMatch != null) {
|
if (whatsNewMatch != null) {
|
||||||
content = content.substring(whatsNewMatch.end);
|
content = content.substring(whatsNewMatch.end);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cut off at "Downloads" section or horizontal rule
|
|
||||||
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
|
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
|
||||||
if (cutoffMatch != null) {
|
if (cutoffMatch != null) {
|
||||||
content = content.substring(0, cutoffMatch.start);
|
content = content.substring(0, cutoffMatch.start);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process line by line for better formatting
|
|
||||||
final lines = content.split('\n');
|
final lines = content.split('\n');
|
||||||
final formattedLines = <String>[];
|
final formattedLines = <String>[];
|
||||||
|
|
||||||
@@ -323,7 +310,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
line = line.trim();
|
line = line.trim();
|
||||||
if (line.isEmpty) continue;
|
if (line.isEmpty) continue;
|
||||||
|
|
||||||
// Check if it's a section header
|
|
||||||
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
|
||||||
if (sectionMatch != null) {
|
if (sectionMatch != null) {
|
||||||
final section = sectionMatch.group(1)?.trim();
|
final section = sectionMatch.group(1)?.trim();
|
||||||
@@ -334,7 +320,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a list item
|
|
||||||
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
|
||||||
if (listMatch != null) {
|
if (listMatch != null) {
|
||||||
var itemText = listMatch.group(1) ?? '';
|
var itemText = listMatch.group(1) ?? '';
|
||||||
@@ -344,7 +329,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a sub-item
|
|
||||||
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
|
||||||
if (subListMatch != null) {
|
if (subListMatch != null) {
|
||||||
var itemText = subListMatch.group(1) ?? '';
|
var itemText = subListMatch.group(1) ?? '';
|
||||||
@@ -401,7 +385,6 @@ class _VersionChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show update dialog
|
|
||||||
Future<void> showUpdateDialog(
|
Future<void> showUpdateDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required UpdateInfo updateInfo,
|
required UpdateInfo updateInfo,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.1.0+59
|
version: 3.1.1+60
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.1.0+59
|
version: 3.1.1+60
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user