mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
33 Commits
v3.3.5
...
v3.5.0-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
| c0a9cb756f | |||
| 5fa00c0051 | |||
| 239e073a8c | |||
| 278ebf3472 | |||
| 7ade57e010 | |||
| 65a152cada | |||
| e4a6177cb5 | |||
| 34ffbca3e8 | |||
| f8acd8f3b6 | |||
| 9956f051ac | |||
| b33ae905a2 | |||
| 11eb0aa12a | |||
| 7c08321ce3 | |||
| e20becdca7 | |||
| 24897e25e2 | |||
| 2dc4cef583 | |||
| 34c95fbd81 | |||
| 9071db9b88 | |||
| 3eb2fdd7fa | |||
| 99e0d3d361 | |||
| a2eb89e230 | |||
| b21e953ef1 | |||
| 0ef086ce57 | |||
| 72d45746a5 | |||
| 9c22f41a3e | |||
| 22f001a735 | |||
| 26d464d3c7 | |||
| 3d6a3f8d04 | |||
| 39ce22a9e2 | |||
| 88f9a65d11 | |||
| 663ee12bcc | |||
| 8c201b5b4a | |||
| 5e19178bc0 |
+122
-32
@@ -1,5 +1,125 @@
|
||||
# Changelog
|
||||
|
||||
## [3.5.0-beta.1] - 2026-02-06
|
||||
|
||||
### Highlights
|
||||
- **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs)
|
||||
- Select download folder via SAF tree picker
|
||||
- Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths
|
||||
- Works around Android 10+ scoped storage permission errors
|
||||
|
||||
### Added
|
||||
|
||||
- Home feed disk caching via SharedPreferences for instant restore on app startup
|
||||
- SAF display path resolver in native Android layer (converts tree URIs to readable paths)
|
||||
- New settings fields for storage mode + SAF tree URI
|
||||
- SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF
|
||||
- SAF library scan mode (DocumentFile traversal + metadata read)
|
||||
- Library UI toggle to show SAF-repaired history items
|
||||
- Scan cancelled banner + retry action for library scans
|
||||
- Android DocumentFile dependency for SAF operations
|
||||
- Post-processing API v2 (SAF-aware, ready to replace v1)
|
||||
|
||||
### Changed
|
||||
|
||||
- Download pipeline supports `output_path` + `output_ext` for Go backend
|
||||
- Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled
|
||||
- Post-processing hooks run for SAF content URIs (via temp file bridge)
|
||||
- File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`)
|
||||
- Android build tooling upgraded to Gradle 9.3.1 (wrapper)
|
||||
- Android build path validated with Java 25 (Gradle/Kotlin/assemble debug)
|
||||
- SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`)
|
||||
- `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility
|
||||
- Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android 10+ `permission denied` when writing to `/storage/emulated/0` (now handled via SAF)
|
||||
- SAF history repair: auto-resolve missing content URIs using tree + filename
|
||||
- SAF download fallback: retry in app-private storage when SAF write fails
|
||||
- Tidal DASH manifest writing when output path is a file descriptor (no invalid `.m4a` path)
|
||||
- External LRC output in SAF mode
|
||||
- Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices)
|
||||
- Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support
|
||||
- SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling
|
||||
|
||||
---
|
||||
|
||||
## [3.4.2] - 2026-02-04
|
||||
|
||||
### Improved
|
||||
|
||||
- **Mobile Network Reliability**: All providers (Qobuz, Tidal, Amazon, Deezer) now have retry logic with exponential backoff
|
||||
- Increased API timeouts: 15s → 25s (Deezer, Qobuz, Tidal), 30s (Amazon)
|
||||
- Up to 3 retry attempts per API call (500ms → 1s → 2s backoff)
|
||||
- Retryable: timeout, connection reset/refused, EOF, HTTP 5xx, HTTP 429
|
||||
- **SongLink ID Extraction**: Extract QobuzID/TidalID directly from SongLink URLs
|
||||
- New fields in `TrackAvailability`: `QobuzID`, `TidalID`
|
||||
- Qobuz/Tidal now use direct Track ID from SongLink instead of re-parsing URLs
|
||||
- **Qobuz Download Flow**: New Strategy 3 - get QobuzID from SongLink before ISRC search
|
||||
- Cache hit now uses `GetTrackByID()` directly instead of searching again
|
||||
- Pre-warm cache tries SongLink first before direct ISRC search
|
||||
- **Tidal Download Flow**: Use `availability.TidalID` directly from SongLink struct
|
||||
|
||||
---
|
||||
|
||||
## [3.4.1] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- Metadata Priority order now persists after app restart
|
||||
- Download Provider Priority order now persists after app restart
|
||||
|
||||
---
|
||||
|
||||
## [3.4.0] - 2026-02-03
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Local Library Scanning** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
|
||||
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
|
||||
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
|
||||
|
||||
### Added
|
||||
|
||||
- Local Album Screen with cover art, disc grouping, and selection mode
|
||||
- Albums tab shows local library albums with folder icon badge
|
||||
- Singles filter includes local library singles
|
||||
- Advanced library filters: Source, Quality, Format, Date
|
||||
- Cover art extraction from embedded tags (FLAC, MP3, Opus/Ogg)
|
||||
- "Already in Library" notification when downloading existing tracks
|
||||
- Spotify secrets now stored in secure storage (`flutter_secure_storage`)
|
||||
- **Multi-Service Link Support**: Share links from Deezer, Tidal, and YouTube Music (in addition to Spotify)
|
||||
- Deezer: Full support for track, album, playlist, artist links
|
||||
- Tidal: Track links converted via SongLink to Spotify/Deezer for metadata
|
||||
- YouTube Music: Handled via ytmusic extension URL handler
|
||||
- Local library tracks now open metadata screen on tap
|
||||
|
||||
### Changed
|
||||
|
||||
- Extension HTTP sandbox enforces HTTPS and blocks private IPs
|
||||
- Extension file sandbox validates paths with boundary-safe checks
|
||||
|
||||
### Fixed
|
||||
|
||||
- Search filter bar now only appears after results load, not during loading
|
||||
- MP3/Ogg metadata parsing (ID3v2 extended headers, Ogg packet reassembly)
|
||||
- Library scan metadata (ISRC, disc number, release date)
|
||||
- Cover cache robustness (size + mtime cache key)
|
||||
- Local library selection and delete in list/grid views
|
||||
- Albums/Singles count includes local library items
|
||||
|
||||
---
|
||||
|
||||
## [3.3.6] - 2026-02-02
|
||||
|
||||
### Added
|
||||
|
||||
- **WiFi-Only Download Mode**: Pause downloads on mobile data, auto-resume on WiFi (Settings > Download > Download Network)
|
||||
- Added `connectivity_plus: ^6.0.3` dependency
|
||||
|
||||
---
|
||||
|
||||
## [3.3.5] - 2026-02-01
|
||||
|
||||
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
@@ -131,31 +251,26 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- Perfect for players like Samsung Music that prefer external .lrc files
|
||||
- LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile)
|
||||
- Works with all download services (Tidal, Qobuz, Amazon)
|
||||
|
||||
- **CSV Import Quality Selection**: Choose audio quality when importing CSV playlists
|
||||
- Quality picker now appears before adding CSV tracks to download queue
|
||||
- Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3
|
||||
- Respects "Ask quality before download" setting - uses default quality if disabled
|
||||
|
||||
- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory
|
||||
- Cover images no longer disappear when app is closed or device restarts
|
||||
- Cache stored in `app_flutter/cover_cache/` directory (not cleared by system)
|
||||
- Maximum 1000 images cached for up to 365 days
|
||||
- Covers are cached when displayed in History, Home, Album, Artist, or any other screen
|
||||
- New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management
|
||||
|
||||
- **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer
|
||||
- New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre`
|
||||
- Metadata fetched during `enrichTrack()` via Deezer album API
|
||||
- Embedded as FLAC Vorbis comments: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
|
||||
- Works for both extension downloads and built-in provider downloads (Tidal, Qobuz, Amazon)
|
||||
|
||||
- **Track Metadata Screen Extended Info**: Genre, label, and copyright now displayed in track metadata screen
|
||||
- Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model
|
||||
- Metadata is stored in download history and persists across app restarts
|
||||
- New localization strings: `trackGenre`, `trackLabel`, `trackCopyright`
|
||||
|
||||
- **`utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
|
||||
- `**utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
|
||||
- Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0`
|
||||
- Useful for extensions that need to rotate User-Agents to avoid detection
|
||||
|
||||
@@ -164,7 +279,6 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- **Portuguese Language Bug**: Fixed locale parsing for languages with country codes (e.g., pt_PT, es_ES)
|
||||
- App now correctly loads Portuguese and Spanish translations
|
||||
- Updated Portuguese label to "Português (Brasil)"
|
||||
|
||||
- **VM Race Condition Panic**: Fixed `panic during execution: runtime error: index out of range [-2]` crash when switching search providers
|
||||
- Root cause: Goja VM was being accessed concurrently by multiple goroutines without synchronization
|
||||
- Added `VMMu sync.Mutex` to `LoadedExtension` struct
|
||||
@@ -173,16 +287,13 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- `EnrichTrack`, `CheckAvailability`, `GetDownloadURL`, `Download`
|
||||
- `CustomSearch`, `HandleURL`, `MatchTrack`, `PostProcess`
|
||||
- Prevents race conditions when rapidly switching between extension search providers
|
||||
|
||||
- **Tidal Release Date Fallback**: Fixed missing release date in FLAC metadata when downloading from Tidal
|
||||
- Now uses Tidal API's release date when `req.ReleaseDate` is empty
|
||||
- Ensures release date is always embedded in downloaded files
|
||||
|
||||
- **Extended Metadata for M4A→FLAC Conversion**: Fixed genre, label, and copyright not being embedded when converting Amazon M4A to FLAC
|
||||
- Flutter now extracts extended metadata from Go backend response
|
||||
- Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()`
|
||||
- Tags correctly embedded during FFmpeg conversion
|
||||
|
||||
- **Extended Metadata for MP3 Conversion**: Genre, label, and copyright now embedded in MP3 files when converting from FLAC
|
||||
- Added `genre`, `label`, `copyright` parameters to `_embedMetadataToMp3()`
|
||||
- Tags embedded as ID3v2: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
|
||||
@@ -243,7 +354,6 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- `go_backend/httputil.go`: Updated `getRandomUserAgent()` to use modern Chrome versions
|
||||
- `go_backend/tidal.go`: Added release date fallback logic
|
||||
- `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse`
|
||||
|
||||
- **Flutter Changes**:
|
||||
- `lib/services/cover_cache_manager.dart`: New persistent cache manager for cover images (365 days, 1000 images max)
|
||||
- `lib/widgets/cached_cover_image.dart`: Wrapper widget for CachedNetworkImage with persistent cache
|
||||
@@ -268,7 +378,6 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
|
||||
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
|
||||
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
|
||||
|
||||
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
|
||||
- Tap the search icon to reveal a dropdown menu with all available search providers
|
||||
- Shows default provider (Deezer based on metadata source setting) at the top
|
||||
@@ -278,56 +387,46 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- Search hint text updates immediately when switching providers
|
||||
- Re-triggers search automatically if there's existing text in the search bar
|
||||
- Eliminates need to navigate to Settings > Extensions > Search Provider
|
||||
|
||||
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
|
||||
- Extensions can define `button` type in manifest settings
|
||||
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
|
||||
- Useful for authentication, manual sync, or any custom action
|
||||
|
||||
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
|
||||
- Fetches genre and label from Deezer album API for each track
|
||||
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
|
||||
- Works automatically when Deezer track ID is available (via ISRC matching)
|
||||
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
|
||||
|
||||
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
|
||||
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
|
||||
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
|
||||
- Available in both the quality picker dialog and default quality settings
|
||||
- Works with all services (Tidal, Qobuz, Amazon) and extensions
|
||||
|
||||
- **MP3 Metadata Embedding**: Full metadata support for MP3 files
|
||||
- Cover art embedded using ID3v2 tags
|
||||
- Synced lyrics embedded (fetched from lrclib.net)
|
||||
- All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC
|
||||
- Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3)
|
||||
|
||||
- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds
|
||||
- Extracts dominant color from cover art using `palette_generator`
|
||||
- Creates a gradient from dominant color to theme surface color
|
||||
- Smooth 500ms color transition animation
|
||||
|
||||
- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed)
|
||||
- More prominent album artwork display
|
||||
- Larger shadow and rounded corners (20px radius)
|
||||
- Higher resolution cover caching
|
||||
|
||||
- **Sticky Title**: Title appears in AppBar when scrolling past the info card
|
||||
- Smooth fade-in animation (200ms) when scrolling down
|
||||
- Title hidden when header is expanded (shows in info card instead)
|
||||
- AppBar uses theme color (surface) for clean, native look
|
||||
- Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens
|
||||
|
||||
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
|
||||
- Extracted from first track's artist metadata
|
||||
- Styled with `onSurfaceVariant` color for visual hierarchy
|
||||
|
||||
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
|
||||
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
|
||||
- Tracks sorted by disc number first, then by track number
|
||||
- Single-disc albums display normally without separators
|
||||
- Fixes confusion when albums have duplicate track numbers across discs
|
||||
|
||||
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
|
||||
- Prevents flooding the recents list when downloading full albums
|
||||
- Groups tracks by album name and artist
|
||||
@@ -340,7 +439,6 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder)
|
||||
- Original FLAC file automatically deleted after successful conversion
|
||||
- New `embedMetadataToMp3()` method for MP3-specific tag embedding
|
||||
|
||||
- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed
|
||||
- Dark theme: Black background with white text
|
||||
- Light theme: White background with black text
|
||||
@@ -352,12 +450,10 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate
|
||||
- History no longer stores FLAC audio specs for converted MP3 files
|
||||
- Both File Info badges and metadata grid show correct MP3 quality
|
||||
|
||||
- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks
|
||||
- `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored
|
||||
- `track_provider.dart`: Added comments explaining why availability check errors are silently ignored
|
||||
- `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures
|
||||
|
||||
- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization
|
||||
- Removed redundant `=1` clauses that were overriding `one` plural category
|
||||
- Affected 10 plural strings including track counts and delete confirmations
|
||||
@@ -377,24 +473,20 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- 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
|
||||
@@ -405,12 +497,10 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
- 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
|
||||
@@ -477,4 +567,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
||||
|
||||
---
|
||||
|
||||
*For older versions, see [GitHub Releases](https://github.com/zarzet/SpotiFLAC-Mobile/releases)*
|
||||
*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
@@ -103,4 +103,6 @@ dependencies {
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.documentfile:documentfile:1.0.1")
|
||||
implementation("androidx.activity:activity-ktx:1.9.0")
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
android:label="SpotiFLAC"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
android:usesCleartextTraffic="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -43,7 +43,7 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Spotify URL sharing -->
|
||||
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -57,6 +57,33 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="open.spotify.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Deezer deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="www.deezer.com" />
|
||||
<data android:scheme="https" android:host="deezer.com" />
|
||||
<data android:scheme="https" android:host="deezer.page.link" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Tidal deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="tidal.com" />
|
||||
<data android:scheme="https" android:host="listen.tidal.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle YouTube Music deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="music.youtube.com" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="es-ES" />
|
||||
<locale android:name="id" />
|
||||
<locale android:name="pt-PT" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="tr" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="hi" />
|
||||
<locale android:name="ko" />
|
||||
<locale android:name="nl" />
|
||||
<locale android:name="zh" />
|
||||
</locale-config>
|
||||
@@ -1,2 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
|
||||
+171
-82
@@ -17,6 +17,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Amazon API timeout and retry configuration for mobile networks
|
||||
const (
|
||||
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
|
||||
amazonMaxRetries = 2 // Number of retry attempts
|
||||
amazonRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
}
|
||||
@@ -36,15 +43,6 @@ type AfkarXYZResponse struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func amazonIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
@@ -54,12 +52,50 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||
return globalAmazonDownloader
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
|
||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
|
||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
|
||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
|
||||
if !isRetryable {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// doAfkarXYZRequest performs a single request to AfkarXYZ API
|
||||
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -98,12 +134,22 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
fileName = reg.ReplaceAllString(fileName, "")
|
||||
|
||||
GoLog("[Amazon] AfkarXYZ returned: %s (%.2f MB)\n", fileName, float64(apiResp.Data.FileSize)/(1024*1024))
|
||||
|
||||
return apiResp.Data.DirectLink, fileName, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||
|
||||
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||
return downloadURL, fileName, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
@@ -142,7 +188,7 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -161,23 +207,23 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
@@ -197,44 +243,63 @@ type AmazonDownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
amazonURL := ""
|
||||
if req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
||||
amazonURL = cached.AmazonURL
|
||||
GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
if amazonURL == "" {
|
||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
amazonURL = availability.AmazonURL
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
if req.OutputDir != "." {
|
||||
if !isSafOutput && req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download using AfkarXYZ API
|
||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
|
||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||
}
|
||||
@@ -249,11 +314,18 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
@@ -273,7 +345,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}()
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
@@ -346,59 +418,70 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
if isSafOutput {
|
||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
quality := AudioQuality{}
|
||||
if isSafOutput {
|
||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
quality, err = GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to ISRC index for fast duplicate checking
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
bitDepth := 0
|
||||
sampleRate := 0
|
||||
@@ -407,6 +490,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
@@ -418,5 +506,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+42
-1
@@ -23,6 +23,11 @@ const (
|
||||
deezerCacheTTL = 10 * time.Minute
|
||||
|
||||
deezerMaxParallelISRC = 10
|
||||
|
||||
// Deezer API timeout and retry configuration for mobile networks
|
||||
deezerAPITimeoutMobile = 25 * time.Second
|
||||
deezerMaxRetries = 2
|
||||
deezerRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type DeezerClient struct {
|
||||
@@ -42,7 +47,7 @@ var (
|
||||
func GetDeezerClient() *DeezerClient {
|
||||
deezerClientOnce.Do(func() {
|
||||
deezerClient = &DeezerClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
httpClient: NewHTTPClientWithTimeout(deezerAPITimeoutMobile),
|
||||
searchCache: make(map[string]*cacheEntry),
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
artistCache: make(map[string]*cacheEntry),
|
||||
@@ -992,6 +997,42 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
|
||||
}
|
||||
|
||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
err := c.doGetJSON(ctx, endpoint, dst)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
|
||||
if !isRetryable {
|
||||
return err
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+131
-14
@@ -128,6 +128,9 @@ type DownloadRequest struct {
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
OutputPath string `json:"output_path,omitempty"`
|
||||
OutputFD int `json:"output_fd,omitempty"`
|
||||
OutputExt string `json:"output_ext,omitempty"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
@@ -194,14 +197,15 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields to prevent filename/path issues
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
req.OutputPath = strings.TrimSpace(req.OutputPath)
|
||||
req.OutputExt = strings.TrimSpace(req.OutputExt)
|
||||
|
||||
if req.OutputDir != "" {
|
||||
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
@@ -241,6 +245,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
@@ -258,6 +263,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
err = amazonErr
|
||||
@@ -337,8 +343,10 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
req.OutputPath = strings.TrimSpace(req.OutputPath)
|
||||
req.OutputExt = strings.TrimSpace(req.OutputExt)
|
||||
|
||||
if req.OutputDir != "" {
|
||||
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
@@ -403,6 +411,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||
@@ -422,6 +431,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: amazonResult.TrackNumber,
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
LyricsLRC: amazonResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||
@@ -570,6 +580,14 @@ func SetDownloadDirectory(path string) error {
|
||||
return setDownloadDir(path)
|
||||
}
|
||||
|
||||
// AllowDownloadDir adds a directory to the extension file sandbox allowlist.
|
||||
func AllowDownloadDir(path string) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return
|
||||
}
|
||||
AddAllowedDownloadDir(path)
|
||||
}
|
||||
|
||||
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
||||
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
||||
|
||||
@@ -736,7 +754,6 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetDeezerMetadata fetches metadata from Deezer URL or ID
|
||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -789,6 +806,51 @@ func ParseDeezerURLExport(url string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ParseTidalURLExport(url string) (string, error) {
|
||||
resourceType, resourceID, err := parseTidalURL(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"type": resourceType,
|
||||
"id": resourceID,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ConvertTidalToSpotifyDeezer(tidalURL string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckAvailabilityFromURL(tidalURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"spotify_id": availability.SpotifyID,
|
||||
"deezer_id": availability.DeezerID,
|
||||
"deezer_url": availability.DeezerURL,
|
||||
"spotify_url": "",
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "" {
|
||||
result["spotify_url"] = "https://open.spotify.com/track/" + availability.SpotifyID
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("empty track ID")
|
||||
@@ -835,8 +897,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
||||
// Useful when Spotify API is rate limited
|
||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -886,7 +946,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||
}
|
||||
|
||||
// GetSpotifyMetadataWithDeezerFallback tries Spotify first, falls back to Deezer on rate limit
|
||||
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -943,7 +1002,6 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CheckAvailabilityByPlatformID checks track availability using any platform as source
|
||||
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
|
||||
@@ -1221,6 +1279,17 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||
return "", fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
req.OutputPath = strings.TrimSpace(req.OutputPath)
|
||||
req.OutputExt = strings.TrimSpace(req.OutputExt)
|
||||
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
result, err := DownloadWithExtensionFallback(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1447,8 +1516,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType, // track, album, or playlist
|
||||
"album_type": track.AlbumType, // album, single, ep, compilation
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1544,7 +1613,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
response["tracks"] = tracks
|
||||
}
|
||||
|
||||
// Add album info if present
|
||||
if result.Album != nil {
|
||||
response["album"] = map[string]interface{}{
|
||||
"id": result.Album.ID,
|
||||
@@ -1662,7 +1730,6 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
}
|
||||
// Use track number from extension, fallback to index+1 if not provided
|
||||
trackNum := track.TrackNumber
|
||||
if trackNum == 0 {
|
||||
trackNum = i + 1
|
||||
@@ -1840,7 +1907,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
"provider_id": artist.ProviderID,
|
||||
}
|
||||
|
||||
// Add header image if present
|
||||
if artist.HeaderImage != "" {
|
||||
response["header_image"] = artist.HeaderImage
|
||||
}
|
||||
@@ -1849,7 +1915,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
response["listeners"] = artist.Listeners
|
||||
}
|
||||
|
||||
// Add top tracks if present
|
||||
if len(artist.TopTracks) > 0 {
|
||||
topTracks := make([]map[string]interface{}, len(artist.TopTracks))
|
||||
for i, track := range artist.TopTracks {
|
||||
@@ -1923,6 +1988,35 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
||||
var metadata map[string]interface{}
|
||||
if metadataJSON != "" {
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||
metadata = make(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
var input PostProcessInput
|
||||
if inputJSON != "" {
|
||||
if err := json.Unmarshal([]byte(inputJSON), &input); err != nil {
|
||||
input = PostProcessInput{}
|
||||
}
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
result, err := manager.RunPostProcessingV2(input, metadata)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetPostProcessingProvidersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
providers := manager.GetPostProcessingProviders()
|
||||
@@ -2089,3 +2183,26 @@ func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
||||
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||
}
|
||||
|
||||
// ==================== LOCAL LIBRARY SCANNING ====================
|
||||
|
||||
// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art
|
||||
func SetLibraryCoverCacheDirJSON(cacheDir string) {
|
||||
SetLibraryCoverCacheDir(cacheDir)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderJSON(folderPath string) (string, error) {
|
||||
return ScanLibraryFolder(folderPath)
|
||||
}
|
||||
|
||||
func GetLibraryScanProgressJSON() string {
|
||||
return GetLibraryScanProgress()
|
||||
}
|
||||
|
||||
func CancelLibraryScanJSON() {
|
||||
CancelLibraryScan()
|
||||
}
|
||||
|
||||
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
||||
return ReadAudioMetadata(filePath)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
@@ -58,8 +58,8 @@ type LoadedExtension struct {
|
||||
type ExtensionManager struct {
|
||||
mu sync.RWMutex
|
||||
extensions map[string]*LoadedExtension
|
||||
extensionsDir string // Base directory for extensions
|
||||
dataDir string // Base directory for extension data
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -98,7 +98,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
// Open the zip file
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||
@@ -221,7 +220,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
// Initialize Goja VM
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
@@ -268,13 +266,11 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// Run the extension code
|
||||
_, err = vm.RunString(string(jsCode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||
}
|
||||
|
||||
// Verify extension was registered
|
||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||
return fmt.Errorf("extension did not call registerExtension()")
|
||||
}
|
||||
@@ -291,9 +287,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
// Call cleanup if VM is initialized
|
||||
if ext.VM != nil {
|
||||
// Try to call cleanup function
|
||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||
if err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||
@@ -302,7 +296,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
delete(m.extensions, extensionID)
|
||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||
|
||||
@@ -343,7 +336,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
ext.Enabled = enabled
|
||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||
|
||||
// Persist enabled state to settings store
|
||||
store := GetExtensionSettingsStore()
|
||||
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
|
||||
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
|
||||
@@ -438,7 +430,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Goja VM
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
@@ -457,12 +448,10 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unload first
|
||||
if err := m.UnloadExtension(extensionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove source directory
|
||||
if ext.SourceDir != "" {
|
||||
if err := os.RemoveAll(ext.SourceDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
||||
@@ -484,7 +473,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
// Open the zip file
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||
@@ -548,11 +536,9 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
extDir := existing.SourceDir
|
||||
wasEnabled := existing.Enabled
|
||||
|
||||
// Cleanup and unload existing extension
|
||||
m.CleanupExtension(existing.ID)
|
||||
m.UnloadExtension(existing.ID)
|
||||
|
||||
// Remove old source files but keep data directory
|
||||
if extDir != "" {
|
||||
if err := os.RemoveAll(extDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
||||
@@ -634,11 +620,11 @@ type ExtensionUpgradeInfo struct {
|
||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
// Validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format")
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
// Open the zip file
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file")
|
||||
}
|
||||
|
||||
@@ -175,7 +175,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate settings if present
|
||||
for i, setting := range m.Settings {
|
||||
if strings.TrimSpace(setting.Key) == "" {
|
||||
return &ManifestValidationError{
|
||||
@@ -236,7 +235,7 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
}
|
||||
// Support wildcard subdomains (e.g., *.example.com)
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
suffix := allowed[1:] // Remove the *
|
||||
suffix := allowed[1:]
|
||||
if strings.HasSuffix(domain, suffix) {
|
||||
return true
|
||||
}
|
||||
@@ -269,7 +268,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||
for _, pattern := range m.URLHandler.Patterns {
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
// Check if URL contains the pattern (host match)
|
||||
if strings.Contains(urlStr, pattern) {
|
||||
return true
|
||||
}
|
||||
|
||||
+175
-161
@@ -25,27 +25,26 @@ type ExtTrackMetadata struct {
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Images string `json:"images,omitempty"` // Alternative field for cover URL (used by some extensions)
|
||||
Images string `json:"images,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
// Enrichment fields from Odesli/song.link
|
||||
ItemType string `json:"item_type,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
|
||||
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
|
||||
Label string `json:"label,omitempty"` // Record label
|
||||
Copyright string `json:"copyright,omitempty"` // Copyright information
|
||||
Genre string `json:"genre,omitempty"` // Music genre(s)
|
||||
ExternalLinks map[string]string `json:"external_links,omitempty"`
|
||||
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
}
|
||||
|
||||
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
|
||||
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||
if t.CoverURL != "" {
|
||||
return t.CoverURL
|
||||
@@ -53,7 +52,6 @@ func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||
return t.Images
|
||||
}
|
||||
|
||||
// ExtAlbumMetadata represents album metadata from an extension
|
||||
type ExtAlbumMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -67,34 +65,28 @@ type ExtAlbumMetadata struct {
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
// ExtArtistMetadata represents artist metadata from an extension
|
||||
type ExtArtistMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
|
||||
Listeners int `json:"listeners,omitempty"` // Monthly listeners
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
Listeners int `json:"listeners,omitempty"`
|
||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks
|
||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
// ExtSearchResult represents search results from an extension
|
||||
type ExtSearchResult struct {
|
||||
Tracks []ExtTrackMetadata `json:"tracks"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ==================== Download Types ====================
|
||||
|
||||
// ExtAvailabilityResult represents availability check result
|
||||
type ExtAvailabilityResult struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TrackID string `json:"track_id,omitempty"`
|
||||
}
|
||||
|
||||
// ExtDownloadURLResult represents download URL info
|
||||
type ExtDownloadURLResult struct {
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
@@ -102,7 +94,6 @@ type ExtDownloadURLResult struct {
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ExtDownloadResult represents download result from an extension
|
||||
type ExtDownloadResult struct {
|
||||
Success bool `json:"success"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
@@ -110,7 +101,7 @@ type ExtDownloadResult struct {
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
// Metadata returned by extension (optional - if provided, can skip enrichment)
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
@@ -122,15 +113,11 @@ type ExtDownloadResult struct {
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Provider Wrapper ====================
|
||||
|
||||
// ExtensionProviderWrapper wraps an extension to call its provider methods
|
||||
type ExtensionProviderWrapper struct {
|
||||
extension *LoadedExtension
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
// NewExtensionProviderWrapper creates a new provider wrapper
|
||||
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
|
||||
return &ExtensionProviderWrapper{
|
||||
extension: ext,
|
||||
@@ -138,9 +125,6 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Metadata Provider Methods ====================
|
||||
|
||||
// SearchTracks searches for tracks using the extension
|
||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -150,11 +134,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Call extension's searchTracks function
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') {
|
||||
@@ -176,7 +158,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
return nil, fmt.Errorf("searchTracks returned null")
|
||||
}
|
||||
|
||||
// Convert result to Go struct
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
@@ -185,14 +166,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
|
||||
var searchResult ExtSearchResult
|
||||
|
||||
// Try to parse as ExtSearchResult object first
|
||||
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
|
||||
// If that fails, try parsing as array of tracks directly
|
||||
var tracks []ExtTrackMetadata
|
||||
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
|
||||
}
|
||||
// Wrap array in ExtSearchResult
|
||||
searchResult = ExtSearchResult{
|
||||
Tracks: tracks,
|
||||
Total: len(tracks),
|
||||
@@ -206,7 +184,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
return &searchResult, nil
|
||||
}
|
||||
|
||||
// GetTrack gets track details by ID
|
||||
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -216,7 +193,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -256,7 +232,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
// GetAlbum gets album details by ID
|
||||
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -266,7 +241,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -309,7 +283,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
// GetArtist gets artist details by ID
|
||||
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -319,7 +292,6 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -359,27 +331,22 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC)
|
||||
// This is called lazily when download starts, not when playlist/album is loaded
|
||||
// Extension should implement enrichTrack(track) function that returns enriched track
|
||||
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return track, nil // Not a metadata provider, return as-is
|
||||
return track, nil
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return track, nil // Extension disabled, return as-is
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Convert track to JSON for passing to JS
|
||||
trackJSON, err := json.Marshal(track)
|
||||
if err != nil {
|
||||
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
|
||||
return track, nil // Return original on error
|
||||
return track, nil
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -399,10 +366,9 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
} else {
|
||||
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
|
||||
}
|
||||
return track, nil // Return original on error
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// If extension doesn't implement enrichTrack or returns null, return original
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return track, nil
|
||||
}
|
||||
@@ -420,18 +386,11 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Preserve provider ID
|
||||
enrichedTrack.ProviderID = track.ProviderID
|
||||
|
||||
GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n",
|
||||
p.extension.ID, track.ISRC, enrichedTrack.ISRC)
|
||||
|
||||
return &enrichedTrack, nil
|
||||
}
|
||||
|
||||
// ==================== Download Provider Methods ====================
|
||||
|
||||
// CheckAvailability checks if a track is available for download
|
||||
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
@@ -441,7 +400,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -480,7 +438,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
return &availability, nil
|
||||
}
|
||||
|
||||
// GetDownloadURL gets the download URL for a track
|
||||
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
@@ -490,7 +447,6 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -529,10 +485,8 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
return &urlResult, nil
|
||||
}
|
||||
|
||||
// ExtDownloadTimeout is longer for extension download operations (5 minutes)
|
||||
const ExtDownloadTimeout = 5 * time.Minute
|
||||
|
||||
// Download downloads a track with progress reporting
|
||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
@@ -542,15 +496,12 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Set up progress callback in VM
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
percent := int(call.Arguments[0].ToInteger())
|
||||
// Clamp to 0-100
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
@@ -573,7 +524,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
})()
|
||||
`, trackID, quality, outputPath)
|
||||
|
||||
// Use longer timeout for downloads (5 minutes)
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
@@ -619,9 +569,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
return &downloadResult, nil
|
||||
}
|
||||
|
||||
// ==================== Extension Manager Provider Methods ====================
|
||||
|
||||
// GetMetadataProviders returns all enabled metadata provider extensions
|
||||
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -635,7 +582,6 @@ func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
||||
return providers
|
||||
}
|
||||
|
||||
// GetDownloadProviders returns all enabled download provider extensions
|
||||
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -649,7 +595,6 @@ func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
||||
return providers
|
||||
}
|
||||
|
||||
// SearchTracksWithExtensions searches all metadata providers
|
||||
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
providers := m.GetMetadataProviders()
|
||||
if len(providers) == 0 {
|
||||
@@ -671,18 +616,12 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
||||
return allTracks, nil
|
||||
}
|
||||
|
||||
// ==================== Provider Priority ====================
|
||||
|
||||
// providerPriority stores the order of download providers
|
||||
var providerPriority []string
|
||||
var providerPriorityMu sync.RWMutex
|
||||
|
||||
// metadataProviderPriority stores the order of metadata providers
|
||||
var metadataProviderPriority []string
|
||||
var metadataProviderPriorityMu sync.RWMutex
|
||||
|
||||
// SetProviderPriority sets the order of download providers
|
||||
// providerIDs should include both built-in ("tidal", "qobuz", "amazon") and extension IDs
|
||||
func SetProviderPriority(providerIDs []string) {
|
||||
providerPriorityMu.Lock()
|
||||
defer providerPriorityMu.Unlock()
|
||||
@@ -690,13 +629,11 @@ func SetProviderPriority(providerIDs []string) {
|
||||
GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
|
||||
}
|
||||
|
||||
// GetProviderPriority returns the current provider priority order
|
||||
func GetProviderPriority() []string {
|
||||
providerPriorityMu.RLock()
|
||||
defer providerPriorityMu.RUnlock()
|
||||
|
||||
if len(providerPriority) == 0 {
|
||||
// Default order: built-in providers first
|
||||
return []string{"tidal", "qobuz", "amazon"}
|
||||
}
|
||||
|
||||
@@ -705,8 +642,6 @@ func GetProviderPriority() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// SetMetadataProviderPriority sets the order of metadata providers
|
||||
// providerIDs should include both built-in ("spotify", "deezer") and extension IDs
|
||||
func SetMetadataProviderPriority(providerIDs []string) {
|
||||
metadataProviderPriorityMu.Lock()
|
||||
defer metadataProviderPriorityMu.Unlock()
|
||||
@@ -714,13 +649,11 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
||||
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
|
||||
}
|
||||
|
||||
// GetMetadataProviderPriority returns the current metadata provider priority order
|
||||
func GetMetadataProviderPriority() []string {
|
||||
metadataProviderPriorityMu.RLock()
|
||||
defer metadataProviderPriorityMu.RUnlock()
|
||||
|
||||
if len(metadataProviderPriority) == 0 {
|
||||
// Default order: built-in providers first
|
||||
return []string{"deezer", "spotify"}
|
||||
}
|
||||
|
||||
@@ -729,7 +662,6 @@ func GetMetadataProviderPriority() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// isBuiltInProvider checks if a provider ID is a built-in provider
|
||||
func isBuiltInProvider(providerID string) bool {
|
||||
switch providerID {
|
||||
case "tidal", "qobuz", "amazon", "deezer":
|
||||
@@ -739,20 +671,12 @@ func isBuiltInProvider(providerID string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Download with Fallback ====================
|
||||
|
||||
// DownloadWithExtensionFallback tries to download from providers in priority order
|
||||
// Includes both built-in providers and extension providers
|
||||
// If req.Source is set (extension ID), that extension is tried first
|
||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||
priority := GetProviderPriority()
|
||||
extManager := GetExtensionManager()
|
||||
|
||||
// If req.Service is a built-in provider, prioritize it first
|
||||
// This handles user's explicit selection from the service picker
|
||||
if req.Service != "" && isBuiltInProvider(req.Service) {
|
||||
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||
// Reorder priority to put req.Service first
|
||||
newPriority := []string{req.Service}
|
||||
for _, p := range priority {
|
||||
if p != req.Service {
|
||||
@@ -764,10 +688,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
|
||||
var skipBuiltIn bool
|
||||
|
||||
// LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC)
|
||||
// This is done lazily at download time, not when playlist/album is loaded
|
||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
||||
@@ -811,7 +733,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if enrichedTrack.Artists != "" {
|
||||
req.ArtistName = enrichedTrack.Artists
|
||||
}
|
||||
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
|
||||
if enrichedTrack.Label != "" && req.Label == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||
req.Label = enrichedTrack.Label
|
||||
@@ -832,7 +753,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If source extension is specified, try it first before the priority list
|
||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
|
||||
|
||||
@@ -842,15 +762,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
|
||||
// For tracks from extension search, use the track ID directly (e.g., "youtube:VIDEO_ID")
|
||||
// The extension already knows how to handle this ID
|
||||
trackID := req.SpotifyID // This contains the extension's track ID (e.g., "youtube:xxx")
|
||||
trackID := req.SpotifyID
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||
|
||||
outputPath := buildOutputPath(req)
|
||||
|
||||
// Download directly using the track ID from the extension
|
||||
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||
@@ -870,7 +787,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
// Embed genre and label if provided (from Deezer metadata)
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
@@ -879,7 +795,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If extension has skipMetadataEnrichment, copy metadata
|
||||
if ext.Manifest.SkipMetadataEnrichment {
|
||||
resp.SkipMetadataEnrichment = true
|
||||
if result.Title != "" {
|
||||
@@ -929,12 +844,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
||||
|
||||
// If skipBuiltInFallback is true, don't continue to other providers
|
||||
if skipBuiltIn {
|
||||
GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n")
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Download failed: %v", lastErr),
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: "extension_error",
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
@@ -944,14 +858,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with priority list
|
||||
for _, providerID := range priority {
|
||||
// Skip if we already tried this as source
|
||||
if providerID == req.Source {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip built-in providers if skipBuiltIn is set
|
||||
if skipBuiltIn && isBuiltInProvider(providerID) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
||||
continue
|
||||
@@ -960,7 +871,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
if isBuiltInProvider(providerID) {
|
||||
// For built-in providers, enrich with Deezer metadata if not already present
|
||||
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -981,11 +891,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// Use built-in provider
|
||||
result, err := tryBuiltInProvider(providerID, req)
|
||||
if err == nil && result.Success {
|
||||
result.Service = providerID
|
||||
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
|
||||
if req.Label != "" {
|
||||
result.Label = req.Label
|
||||
}
|
||||
@@ -1013,7 +921,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
||||
}
|
||||
} else {
|
||||
// Try extension provider
|
||||
ext, err := extManager.GetExtension(providerID)
|
||||
if err != nil || !ext.Enabled || ext.Error != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
|
||||
@@ -1056,7 +963,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
// Embed genre and label if provided (from Deezer metadata)
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||
@@ -1065,10 +971,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If extension has skipMetadataEnrichment and returned metadata, use it
|
||||
if ext.Manifest.SkipMetadataEnrichment {
|
||||
resp.SkipMetadataEnrichment = true
|
||||
// Copy metadata from extension result if provided
|
||||
if result.Title != "" {
|
||||
resp.Title = result.Title
|
||||
}
|
||||
@@ -1121,7 +1025,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if lastErr != nil {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("All providers failed. Last error: %v", lastErr),
|
||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||
ErrorType: "not_found",
|
||||
}, nil
|
||||
}
|
||||
@@ -1133,7 +1037,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tryBuiltInProvider attempts download from a built-in provider
|
||||
func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) {
|
||||
req.Service = providerID
|
||||
|
||||
@@ -1219,8 +1122,11 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildOutputPath builds the output file path from request
|
||||
func buildOutputPath(req DownloadRequest) string {
|
||||
if strings.TrimSpace(req.OutputPath) != "" {
|
||||
return strings.TrimSpace(req.OutputPath)
|
||||
}
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -1236,12 +1142,16 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename)
|
||||
ext := strings.TrimSpace(req.OutputExt)
|
||||
if ext == "" {
|
||||
ext = ".flac"
|
||||
} else if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
|
||||
}
|
||||
|
||||
// ==================== Custom Search ====================
|
||||
|
||||
// CustomSearch performs a custom search using an extension's search function
|
||||
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.HasCustomSearch() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
|
||||
@@ -1251,11 +1161,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Convert options to JSON
|
||||
optionsJSON, _ := json.Marshal(options)
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -1276,7 +1184,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
// Return empty array instead of error for no results
|
||||
return []ExtTrackMetadata{}, nil
|
||||
}
|
||||
|
||||
@@ -1291,7 +1198,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
return nil, fmt.Errorf("failed to parse search result: %w", err)
|
||||
}
|
||||
|
||||
// Return empty array if no tracks found
|
||||
if tracks == nil {
|
||||
tracks = []ExtTrackMetadata{}
|
||||
}
|
||||
@@ -1303,20 +1209,16 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// ==================== Custom URL Handler ====================
|
||||
|
||||
// ExtURLHandleResult represents the result of URL handling
|
||||
type ExtURLHandleResult struct {
|
||||
Type string `json:"type"` // "track", "album", "playlist", "artist"
|
||||
Track *ExtTrackMetadata `json:"track,omitempty"` // For single track
|
||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"` // For album/playlist
|
||||
Album *ExtAlbumMetadata `json:"album,omitempty"` // Album info
|
||||
Artist *ExtArtistMetadata `json:"artist,omitempty"` // Artist info
|
||||
Name string `json:"name,omitempty"` // Playlist/album name
|
||||
CoverURL string `json:"cover_url,omitempty"` // Cover image
|
||||
Type string `json:"type"`
|
||||
Track *ExtTrackMetadata `json:"track,omitempty"`
|
||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
||||
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
||||
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
}
|
||||
|
||||
// HandleURL processes a URL using the extension's URL handler
|
||||
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||
if !p.extension.Manifest.HasURLHandler() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
|
||||
@@ -1326,7 +1228,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -1362,7 +1263,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
|
||||
}
|
||||
|
||||
// Set provider ID on tracks
|
||||
if handleResult.Track != nil {
|
||||
handleResult.Track.ProviderID = p.extension.ID
|
||||
}
|
||||
@@ -1391,9 +1291,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
return &handleResult, nil
|
||||
}
|
||||
|
||||
// ==================== Custom Track Matching ====================
|
||||
|
||||
// MatchTrackResult represents the result of custom track matching
|
||||
type MatchTrackResult struct {
|
||||
Matched bool `json:"matched"`
|
||||
TrackID string `json:"track_id,omitempty"`
|
||||
@@ -1401,7 +1298,6 @@ type MatchTrackResult struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// MatchTrack uses extension's custom matching algorithm
|
||||
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
|
||||
if !p.extension.Manifest.HasCustomMatching() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
|
||||
@@ -1411,7 +1307,6 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -1453,22 +1348,26 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
return &matchResult, nil
|
||||
}
|
||||
|
||||
// ==================== Post-Processing ====================
|
||||
|
||||
// PostProcessResult represents the result of post-processing
|
||||
type PostProcessResult struct {
|
||||
Success bool `json:"success"`
|
||||
NewFilePath string `json:"new_file_path,omitempty"`
|
||||
NewFileURI string `json:"new_file_uri,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Additional metadata that may have changed
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
}
|
||||
|
||||
type PostProcessInput struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsSAF bool `json:"is_saf,omitempty"`
|
||||
}
|
||||
|
||||
// PostProcessTimeout is longer for post-processing (2 minutes)
|
||||
const PostProcessTimeout = 2 * time.Minute
|
||||
|
||||
// PostProcess runs post-processing hooks on a downloaded file
|
||||
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
if !p.extension.Manifest.HasPostProcessing() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||
@@ -1478,7 +1377,6 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
// Lock VM to prevent concurrent access
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
@@ -1532,9 +1430,75 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
return &postResult, nil
|
||||
}
|
||||
|
||||
// ==================== Extension Manager Advanced Methods ====================
|
||||
func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||
if !p.extension.Manifest.HasPostProcessing() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
filePath := input.Path
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined') {
|
||||
if (typeof extension.postProcessV2 === 'function') {
|
||||
return extension.postProcessV2(%s, %s, %q);
|
||||
}
|
||||
if (typeof extension.postProcess === 'function') {
|
||||
return extension.postProcess(%q, %s, %q);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if IsTimeoutError(err) {
|
||||
errMsg = "postProcess timeout: extension took too long to complete"
|
||||
}
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: errMsg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: "postProcess returned null",
|
||||
}, nil
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to marshal result: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var postResult PostProcessResult
|
||||
if err := json.Unmarshal(jsonBytes, &postResult); err != nil {
|
||||
return &PostProcessResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to parse result: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &postResult, nil
|
||||
}
|
||||
|
||||
// GetSearchProviders returns all extensions that provide custom search
|
||||
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -1548,7 +1512,6 @@ func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
||||
return providers
|
||||
}
|
||||
|
||||
// GetURLHandlers returns all extensions that handle custom URLs
|
||||
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -1562,7 +1525,6 @@ func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
||||
return providers
|
||||
}
|
||||
|
||||
// FindURLHandler finds an extension that can handle the given URL
|
||||
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -1575,14 +1537,11 @@ func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtURLHandleResultWithExtID wraps ExtURLHandleResult with extension ID for gomobile compatibility
|
||||
type ExtURLHandleResultWithExtID struct {
|
||||
Result *ExtURLHandleResult
|
||||
ExtensionID string
|
||||
}
|
||||
|
||||
// HandleURLWithExtension tries to handle a URL with any matching extension
|
||||
// Returns result with extension ID, or error if no handler found
|
||||
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
||||
handler := m.FindURLHandler(url)
|
||||
if handler == nil {
|
||||
@@ -1662,3 +1621,58 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
||||
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
||||
}
|
||||
|
||||
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
|
||||
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||
providers := m.GetPostProcessingProviders()
|
||||
if len(providers) == 0 {
|
||||
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
||||
}
|
||||
|
||||
currentInput := input
|
||||
for _, provider := range providers {
|
||||
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
||||
for _, hook := range hooks {
|
||||
if !hook.DefaultEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(currentInput.Path))
|
||||
if ext == "" && currentInput.Name != "" {
|
||||
ext = strings.ToLower(filepath.Ext(currentInput.Name))
|
||||
}
|
||||
if len(hook.SupportedFormats) > 0 && ext != "" {
|
||||
supported := false
|
||||
for _, format := range hook.SupportedFormats {
|
||||
if "."+format == ext || format == ext[1:] {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[PostProcessV2] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentInput.Path)
|
||||
|
||||
result, err := provider.PostProcessV2(currentInput, metadata, hook.ID)
|
||||
if err != nil {
|
||||
GoLog("[PostProcessV2] Hook %s failed: %v\n", hook.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if result.Success && result.NewFilePath != "" {
|
||||
currentInput.Path = result.NewFilePath
|
||||
if currentInput.Name == "" {
|
||||
currentInput.Name = filepath.Base(result.NewFilePath)
|
||||
}
|
||||
}
|
||||
if result.Success && result.NewFileURI != "" {
|
||||
currentInput.URI = result.NewFileURI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -103,8 +106,16 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// Validate redirect target domain against allowed domains
|
||||
if req.URL.Scheme != "https" {
|
||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||
}
|
||||
|
||||
domain := req.URL.Hostname()
|
||||
if domain == "" {
|
||||
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
|
||||
return fmt.Errorf("redirect blocked: hostname is required")
|
||||
}
|
||||
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain}
|
||||
@@ -113,7 +124,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||
}
|
||||
// Default redirect limit (10)
|
||||
if len(via) >= 10 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
@@ -139,35 +149,48 @@ func (e *RedirectBlockedError) Error() string {
|
||||
|
||||
// isPrivateIP checks if a hostname resolves to a private/local IP address
|
||||
func isPrivateIP(host string) bool {
|
||||
// Block common private network patterns
|
||||
// This is a simple check - for production, consider DNS resolution
|
||||
privatePatterns := []string{
|
||||
"localhost",
|
||||
"127.",
|
||||
"10.",
|
||||
"172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.",
|
||||
"172.24.", "172.25.", "172.26.", "172.27.",
|
||||
"172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"192.168.",
|
||||
"169.254.",
|
||||
"::1",
|
||||
"fc00:",
|
||||
"fe80:",
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
if hostLower == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
hostLower := host
|
||||
for _, pattern := range privatePatterns {
|
||||
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
|
||||
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(hostLower); ip != nil {
|
||||
return isPrivateIPAddr(ip)
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(hostLower)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isPrivateIPAddr(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also block .local domains
|
||||
if len(host) > 6 && host[len(host)-6:] == ".local" {
|
||||
return false
|
||||
}
|
||||
|
||||
func isPrivateIPAddr(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
if !ip.IsGlobalUnicast() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -202,7 +225,6 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
// HTTP client (sandboxed to allowed domains)
|
||||
httpObj := vm.NewObject()
|
||||
httpObj.Set("get", r.httpGet)
|
||||
httpObj.Set("post", r.httpPost)
|
||||
@@ -239,7 +261,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
// File operations (sandboxed)
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
@@ -257,7 +278,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||
vm.Set("ffmpeg", ffmpegObj)
|
||||
|
||||
// Track matching API
|
||||
matchingObj := vm.NewObject()
|
||||
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||
|
||||
@@ -121,7 +121,6 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authIsAuthenticated checks if extension has valid auth
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -194,7 +193,6 @@ func generatePKCEChallenge(verifier string) string {
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
// Default length is 64 characters
|
||||
length := 64
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||
@@ -247,9 +245,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
// Returns: { success, authUrl, pkce: { verifier, challenge } }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -267,7 +263,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
authURL, _ := config["authUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
@@ -279,11 +274,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
})
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
|
||||
// Generate PKCE
|
||||
verifier, err := generatePKCEVerifier(64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -293,7 +286,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store PKCE in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -302,10 +294,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
// Build OAuth URL with PKCE parameters
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -325,7 +316,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
query.Set("scope", scope)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
for k, v := range extraParams {
|
||||
query.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
@@ -333,7 +323,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
fullAuthURL := parsedURL.String()
|
||||
|
||||
// Store pending auth request for Flutter
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
|
||||
@@ -64,7 +64,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
|
||||
command := call.Arguments[0].String()
|
||||
|
||||
// Generate unique command ID
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommandID++
|
||||
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||
@@ -77,7 +76,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
|
||||
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||
|
||||
// Wait for completion (with timeout)
|
||||
timeout := 5 * time.Minute
|
||||
start := time.Now()
|
||||
for {
|
||||
@@ -97,7 +95,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
// Cleanup
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
@@ -124,7 +121,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
|
||||
// Use Go's built-in audio quality function
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -153,7 +149,6 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
inputPath := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Get options if provided
|
||||
options := map[string]interface{}{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||
@@ -161,36 +156,29 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Build FFmpeg command
|
||||
var cmdParts []string
|
||||
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||
|
||||
// Audio codec
|
||||
if codec, ok := options["codec"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-c:a", codec)
|
||||
}
|
||||
|
||||
// Bitrate
|
||||
if bitrate, ok := options["bitrate"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||
}
|
||||
|
||||
// Sample rate
|
||||
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||
}
|
||||
|
||||
// Channels
|
||||
if channels, ok := options["channels"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||
}
|
||||
|
||||
// Overwrite output
|
||||
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||
|
||||
command := strings.Join(cmdParts, " ")
|
||||
|
||||
// Execute via ffmpegExecute
|
||||
execCall := goja.FunctionCall{
|
||||
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||
}
|
||||
|
||||
@@ -41,15 +41,40 @@ func isPathInAllowedDirs(absPath string) bool {
|
||||
defer allowedDownloadDirsMu.RUnlock()
|
||||
|
||||
for _, allowedDir := range allowedDownloadDirs {
|
||||
if strings.HasPrefix(absPath, allowedDir) {
|
||||
if isPathWithinBase(allowedDir, absPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPathWithinBase(baseDir, targetPath string) bool {
|
||||
baseAbs, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(baseAbs, targetAbs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.Clean(rel)
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
|
||||
prefix := ".." + string(filepath.Separator)
|
||||
if rel == ".." || strings.HasPrefix(rel, prefix) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
// Check if extension has file permission
|
||||
if !r.manifest.Permissions.File {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
@@ -77,7 +102,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
}
|
||||
|
||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||
if !strings.HasPrefix(absPath, absDataDir) {
|
||||
if !isPathWithinBase(absDataDir, absPath) {
|
||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||
}
|
||||
|
||||
@@ -323,7 +348,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -26,9 +26,18 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
if parsed.Scheme == "" {
|
||||
return fmt.Errorf("invalid URL: scheme is required")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
if domain == "" {
|
||||
return fmt.Errorf("invalid URL: hostname is required")
|
||||
}
|
||||
|
||||
// Block private/local network access (SSRF protection)
|
||||
if isPrivateIP(domain) {
|
||||
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
// ==================== Track Matching API ====================
|
||||
|
||||
// matchingCompareStrings compares two strings with fuzzy matching
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
@@ -22,12 +21,10 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
||||
return r.vm.ToValue(1.0)
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance-based similarity
|
||||
similarity := calculateStringSimilarity(str1, str2)
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
// matchingCompareDuration compares two durations with tolerance
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -36,8 +33,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
dur1 := int(call.Arguments[0].ToInteger())
|
||||
dur2 := int(call.Arguments[1].ToInteger())
|
||||
|
||||
// Default tolerance: 3 seconds
|
||||
tolerance := 3000 // milliseconds
|
||||
tolerance := 3000
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||
tolerance = int(call.Arguments[2].ToInteger())
|
||||
}
|
||||
@@ -50,7 +46,6 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
// matchingNormalizeString normalizes a string for comparison
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -61,7 +56,6 @@ func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(normalized)
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates similarity between two strings (0-1)
|
||||
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if len(s1) == 0 && len(s2) == 0 {
|
||||
return 1.0
|
||||
@@ -70,7 +64,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Use Levenshtein distance
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
maxLen := len(s1)
|
||||
if len(s2) > maxLen {
|
||||
@@ -80,7 +73,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
@@ -89,7 +81,6 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
// Create matrix
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
@@ -99,7 +90,6 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 1
|
||||
@@ -107,9 +97,9 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
cost = 0
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1, // deletion
|
||||
matrix[i][j-1]+1, // insertion
|
||||
matrix[i-1][j-1]+cost, // substitution
|
||||
matrix[i-1][j]+1,
|
||||
matrix[i][j-1]+1,
|
||||
matrix[i-1][j-1]+cost,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -117,12 +107,9 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
// normalizeStringForMatching normalizes a string for comparison
|
||||
func normalizeStringForMatching(s string) string {
|
||||
// Convert to lowercase
|
||||
s = strings.ToLower(s)
|
||||
|
||||
// Remove common suffixes/prefixes
|
||||
suffixes := []string{
|
||||
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||
@@ -136,7 +123,6 @@ func normalizeStringForMatching(s string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove special characters
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||
@@ -144,7 +130,6 @@ func normalizeStringForMatching(s string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse multiple spaces
|
||||
s = strings.Join(strings.Fields(result.String()), " ")
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
|
||||
@@ -25,14 +25,11 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Parse options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
@@ -40,7 +37,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Headers
|
||||
if h, ok := opts["headers"]; ok && h != nil {
|
||||
switch hv := h.(type) {
|
||||
case map[string]interface{}:
|
||||
@@ -73,7 +68,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
@@ -84,11 +78,9 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Set defaults if not provided
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
@@ -96,20 +88,17 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
@@ -119,7 +108,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create Response object (browser-compatible)
|
||||
responseObj := r.vm.NewObject()
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
@@ -127,15 +115,12 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
responseObj.Set("headers", respHeaders)
|
||||
responseObj.Set("url", urlStr)
|
||||
|
||||
// Store body for methods
|
||||
bodyString := string(body)
|
||||
|
||||
// text() method - returns body as string
|
||||
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(bodyString)
|
||||
})
|
||||
|
||||
// json() method - parses body as JSON
|
||||
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
@@ -145,9 +130,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
})
|
||||
|
||||
// arrayBuffer() method - returns body as array (simplified)
|
||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||
// Return as array of bytes
|
||||
byteArray := make([]interface{}, len(body))
|
||||
for i, b := range body {
|
||||
byteArray[i] = int(b)
|
||||
@@ -182,7 +165,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
// Try URL-safe base64
|
||||
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||
@@ -203,12 +185,10 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
|
||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
// TextEncoder constructor
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
encoder.Set("encoding", "utf-8")
|
||||
|
||||
// encode() method - string to Uint8Array
|
||||
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]byte{})
|
||||
@@ -216,7 +196,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
input := call.Arguments[0].String()
|
||||
bytes := []byte(input)
|
||||
|
||||
// Return as array (Uint8Array-like)
|
||||
result := make([]interface{}, len(bytes))
|
||||
for i, b := range bytes {
|
||||
result[i] = int(b)
|
||||
@@ -224,7 +203,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
|
||||
// encodeInto() method
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
// Simplified implementation
|
||||
if len(call.Arguments) < 2 {
|
||||
@@ -240,11 +218,9 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
return nil
|
||||
})
|
||||
|
||||
// TextDecoder constructor
|
||||
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
decoder := call.This
|
||||
|
||||
// Get encoding from arguments (default: utf-8)
|
||||
encoding := "utf-8"
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
encoding = call.Arguments[0].String()
|
||||
@@ -253,13 +229,11 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
decoder.Set("fatal", false)
|
||||
decoder.Set("ignoreBOM", false)
|
||||
|
||||
// decode() method - Uint8Array to string
|
||||
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
// Handle different input types
|
||||
input := call.Arguments[0].Export()
|
||||
var bytes []byte
|
||||
|
||||
@@ -279,7 +253,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
}
|
||||
}
|
||||
case string:
|
||||
// Already a string, just return it
|
||||
return vm.ToValue(v)
|
||||
default:
|
||||
return vm.ToValue("")
|
||||
@@ -292,7 +265,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
// registerURLClass registers the URL class for URL parsing
|
||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||
urlObj := call.This
|
||||
@@ -304,7 +276,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Handle relative URLs with base
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
baseStr := call.Arguments[1].String()
|
||||
baseURL, err := url.Parse(baseStr)
|
||||
@@ -322,7 +293,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set URL properties
|
||||
urlObj.Set("href", parsed.String())
|
||||
urlObj.Set("protocol", parsed.Scheme+":")
|
||||
urlObj.Set("host", parsed.Host)
|
||||
@@ -342,10 +312,9 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
password, _ := parsed.User.Password()
|
||||
urlObj.Set("password", password)
|
||||
|
||||
// searchParams object
|
||||
searchParams := vm.NewObject()
|
||||
queryValues := parsed.Query()
|
||||
|
||||
searchParams := vm.NewObject()
|
||||
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
@@ -379,12 +348,10 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
|
||||
urlObj.Set("searchParams", searchParams)
|
||||
|
||||
// toString method
|
||||
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
// toJSON method
|
||||
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
@@ -392,17 +359,14 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
return nil
|
||||
})
|
||||
|
||||
// URLSearchParams constructor
|
||||
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||
paramsObj := call.This
|
||||
values := url.Values{}
|
||||
|
||||
// Parse initial value if provided
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
init := call.Arguments[0].Export()
|
||||
switch v := init.(type) {
|
||||
case string:
|
||||
// Parse query string
|
||||
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||
values = parsed
|
||||
case map[string]interface{}:
|
||||
@@ -468,10 +432,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
// registerJSONGlobal ensures JSON global is properly set up
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
// JSON is already built-in to Goja, but we can enhance it
|
||||
// This ensures JSON.parse and JSON.stringify work as expected
|
||||
|
||||
// The built-in JSON object should already work, but let's verify
|
||||
// and add any missing functionality if needed
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
|
||||
@@ -15,7 +15,6 @@ type ExtensionSettingsStore struct {
|
||||
settings map[string]map[string]interface{} // extensionID -> settings
|
||||
}
|
||||
|
||||
// Global settings store
|
||||
var (
|
||||
globalSettingsStore *ExtensionSettingsStore
|
||||
globalSettingsStoreOnce sync.Once
|
||||
@@ -129,7 +128,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Return a copy
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range extSettings {
|
||||
result[k] = v
|
||||
@@ -156,7 +154,6 @@ func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]
|
||||
|
||||
s.settings[extensionID] = settings
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, settings)
|
||||
}
|
||||
|
||||
@@ -171,7 +168,6 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||
|
||||
delete(extSettings, key)
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, extSettings)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Extension categories
|
||||
const (
|
||||
CategoryMetadata = "metadata"
|
||||
CategoryDownload = "download"
|
||||
@@ -145,7 +145,6 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
// Try to load from disk cache
|
||||
extensionStore.loadDiskCache()
|
||||
}
|
||||
return extensionStore
|
||||
@@ -208,18 +207,20 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
// Return cached if valid and not forcing refresh
|
||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(s.registryURL)
|
||||
if err != nil {
|
||||
// Return cached data if available on network error
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
@@ -303,6 +304,10 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
@@ -332,6 +337,20 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireHTTPSURL(rawURL string, context string) error {
|
||||
if rawURL == "" {
|
||||
return fmt.Errorf("%s URL is empty", context)
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
|
||||
@@ -112,7 +112,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test allowed domains
|
||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||
}
|
||||
@@ -121,7 +120,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||
}
|
||||
|
||||
// Test blocked domains
|
||||
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||
t.Error("Expected blocked.com to be denied")
|
||||
}
|
||||
@@ -139,7 +137,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: true, // Enable file permission for test
|
||||
File: true,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
@@ -147,7 +145,6 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test valid path within sandbox
|
||||
validPath, err := runtime.validatePath("test.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||
@@ -156,13 +153,11 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
t.Error("Expected non-empty path")
|
||||
}
|
||||
|
||||
// Test path traversal attack
|
||||
_, err = runtime.validatePath("../../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("Expected path traversal to be blocked")
|
||||
}
|
||||
|
||||
// Test nested path within sandbox (should be allowed)
|
||||
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||
@@ -171,26 +166,23 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
t.Error("Expected non-empty nested path")
|
||||
}
|
||||
|
||||
// Test absolute path should be blocked (security fix)
|
||||
// Use platform-appropriate absolute path
|
||||
var absPath string
|
||||
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||
absPath = "C:\\Windows\\System32\\test.txt" // Windows
|
||||
absPath = "C:\\Windows\\System32\\test.txt"
|
||||
} else {
|
||||
absPath = "/etc/passwd" // Unix
|
||||
absPath = "/etc/passwd"
|
||||
}
|
||||
_, err = runtime.validatePath(absPath)
|
||||
if err == nil {
|
||||
t.Error("Expected absolute path to be blocked")
|
||||
}
|
||||
|
||||
// Test that extension without file permission is blocked
|
||||
extNoFile := &LoadedExtension{
|
||||
ID: "test-ext-no-file",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext-no-file",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: false, // No file permission
|
||||
File: false,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
@@ -215,7 +207,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
|
||||
// Test base64 encode/decode
|
||||
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("base64Encode failed: %v", err)
|
||||
@@ -232,7 +223,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||
}
|
||||
|
||||
// Test MD5
|
||||
result, err = vm.RunString(`utils.md5("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("md5 failed: %v", err)
|
||||
@@ -241,7 +231,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||
}
|
||||
|
||||
// Test JSON parse/stringify
|
||||
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||
if err != nil {
|
||||
t.Fatalf("stringifyJSON failed: %v", err)
|
||||
@@ -267,7 +256,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test that private IPs are blocked (SSRF protection)
|
||||
privateIPs := []string{
|
||||
"http://localhost/admin",
|
||||
"http://127.0.0.1/admin",
|
||||
@@ -285,7 +273,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test that allowed public domain still works
|
||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||
}
|
||||
@@ -296,7 +283,6 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
host string
|
||||
expected bool
|
||||
}{
|
||||
// Private IPs should be blocked
|
||||
{"localhost", true},
|
||||
{"127.0.0.1", true},
|
||||
{"127.0.0.2", true},
|
||||
@@ -306,18 +292,17 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
{"172.31.255.255", true},
|
||||
{"192.168.0.1", true},
|
||||
{"192.168.255.255", true},
|
||||
{"169.254.169.254", true}, // AWS metadata
|
||||
{"169.254.169.254", true},
|
||||
{"router.local", true},
|
||||
{"mydevice.local", true},
|
||||
|
||||
// Public IPs should be allowed
|
||||
{"8.8.8.8", false},
|
||||
{"1.1.1.1", false},
|
||||
{"api.example.com", false},
|
||||
{"google.com", false},
|
||||
{"172.15.0.1", false}, // Just outside 172.16-31 range
|
||||
{"172.32.0.1", false}, // Just outside 172.16-31 range
|
||||
{"192.167.0.1", false}, // Not 192.168.x.x
|
||||
{"172.15.0.1", false},
|
||||
{"172.32.0.1", false},
|
||||
{"192.167.0.1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// JSExecutionError represents an error during JS execution
|
||||
type JSExecutionError struct {
|
||||
Message string
|
||||
IsTimeout bool
|
||||
@@ -20,8 +19,6 @@ func (e *JSExecutionError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// RunWithTimeout executes JavaScript code with a timeout
|
||||
// Returns the result value and any error (including timeout)
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultJSTimeout
|
||||
@@ -30,22 +27,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Channel to receive result
|
||||
type result struct {
|
||||
value goja.Value
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
|
||||
// Track if we've interrupted
|
||||
var interrupted bool
|
||||
var interruptMu sync.Mutex
|
||||
|
||||
// Run script in goroutine
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Check if this was our interrupt
|
||||
interruptMu.Lock()
|
||||
wasInterrupted := interrupted
|
||||
interruptMu.Unlock()
|
||||
@@ -65,22 +58,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
resultCh <- result{val, err}
|
||||
}()
|
||||
|
||||
// Wait for result or timeout
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.value, res.err
|
||||
case <-ctx.Done():
|
||||
// Timeout - interrupt the VM
|
||||
interruptMu.Lock()
|
||||
interrupted = true
|
||||
interruptMu.Unlock()
|
||||
|
||||
vm.Interrupt("execution timeout")
|
||||
|
||||
// Wait a bit for the goroutine to finish
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
// If we got a result after interrupt, it might be the timeout error
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
@@ -89,7 +78,6 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
IsTimeout: true,
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
// Force return timeout error
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
@@ -109,7 +97,6 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
|
||||
return result, err
|
||||
}
|
||||
|
||||
// IsTimeoutError checks if an error is a timeout error
|
||||
func IsTimeoutError(err error) bool {
|
||||
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||
return jsErr.IsTimeout
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
)
|
||||
|
||||
func getRandomUserAgent() string {
|
||||
// Chrome version 120-145 (modern range)
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
chromeBuild := rand.Intn(1500) + 6000
|
||||
chromePatch := rand.Intn(200) + 100
|
||||
@@ -118,7 +117,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
requestURL := req.URL.String()
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
// Clone request for retry (body needs to be re-readable)
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
@@ -126,9 +124,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
// Check for ISP blocking on network errors
|
||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||
// Don't retry if ISP blocking is detected - it won't help
|
||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ func newUTLSTransport() *utlsTransport {
|
||||
}
|
||||
|
||||
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// For non-HTTPS, use standard transport
|
||||
if req.URL.Scheme != "https" {
|
||||
return sharedTransport.RoundTrip(req)
|
||||
}
|
||||
@@ -44,29 +43,24 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
port := t.getPort(req.URL)
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
// Dial TCP connection
|
||||
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create uTLS connection with Chrome fingerprint (supports HTTP/2 ALPN)
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"}, // Prefer HTTP/2
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}, utls.HelloChrome_Auto)
|
||||
|
||||
// Perform TLS handshake
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if server supports HTTP/2
|
||||
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||
|
||||
if negotiatedProto == "h2" {
|
||||
// Use HTTP/2 transport
|
||||
h2Transport := &http2.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
@@ -77,7 +71,6 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return h2Transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// Fallback to HTTP/1.1
|
||||
transport := &http.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LibraryScanResult represents metadata from a scanned audio file
|
||||
type LibraryScanResult struct {
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||
FilePath string `json:"filePath"`
|
||||
CoverPath string `json:"coverPath,omitempty"`
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type LibraryScanProgress struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
ScannedFiles int `json:"scanned_files"`
|
||||
CurrentFile string `json:"current_file"`
|
||||
ErrorCount int `json:"error_count"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
IsComplete bool `json:"is_complete"`
|
||||
}
|
||||
|
||||
var (
|
||||
libraryScanProgress LibraryScanProgress
|
||||
libraryScanProgressMu sync.RWMutex
|
||||
libraryScanCancel chan struct{}
|
||||
libraryScanCancelMu sync.Mutex
|
||||
libraryCoverCacheDir string
|
||||
libraryCoverCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
var supportedAudioFormats = map[string]bool{
|
||||
".flac": true,
|
||||
".m4a": true,
|
||||
".mp3": true,
|
||||
".opus": true,
|
||||
".ogg": true,
|
||||
}
|
||||
|
||||
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||
libraryCoverCacheMu.Lock()
|
||||
libraryCoverCacheDir = cacheDir
|
||||
libraryCoverCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
if folderPath == "" {
|
||||
return "[]", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
|
||||
info, err := os.Stat(folderPath)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("folder not found: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "[]", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
}
|
||||
libraryScanCancel = make(chan struct{})
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
var audioFiles []string
|
||||
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if supportedAudioFormats[ext] {
|
||||
audioFiles = append(audioFiles, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
totalFiles := len(audioFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
if totalFiles == 0 {
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgressMu.Unlock()
|
||||
return "[]", nil
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
|
||||
|
||||
results := make([]LibraryScanResult, 0, totalFiles)
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
for i, filePath := range audioFiles {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return "[]", fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ErrorCount = errorCount
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount)
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("failed to marshal results: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
result := &LibraryScanResult{
|
||||
ID: generateLibraryID(filePath),
|
||||
FilePath: filePath,
|
||||
ScannedAt: scanTime,
|
||||
Format: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" && ext != ".m4a" {
|
||||
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
||||
if err == nil && coverPath != "" {
|
||||
result.CoverPath = coverPath
|
||||
}
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result)
|
||||
case ".m4a":
|
||||
return scanM4AFile(filePath, result)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result)
|
||||
case ".opus", ".ogg":
|
||||
return scanOggFile(filePath, result)
|
||||
default:
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
}
|
||||
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Genre = metadata.Genre
|
||||
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.ISRC = metadata.ISRC
|
||||
|
||||
quality, err := GetMP3Quality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadOggVorbisComments(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.Genre = metadata.Genre
|
||||
result.ReleaseDate = metadata.Date
|
||||
|
||||
quality, err := GetOggQuality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
|
||||
parts := strings.SplitN(filename, " - ", 2)
|
||||
if len(parts) == 2 {
|
||||
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
|
||||
result.TrackName = parts[1]
|
||||
result.ArtistName = "Unknown Artist"
|
||||
} else {
|
||||
result.ArtistName = parts[0]
|
||||
result.TrackName = parts[1]
|
||||
}
|
||||
} else {
|
||||
if len(filename) > 3 && isNumeric(filename[:2]) {
|
||||
title := strings.TrimLeft(filename[2:], " .-")
|
||||
result.TrackName = title
|
||||
} else {
|
||||
result.TrackName = filename
|
||||
}
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
result.AlbumName = filepath.Base(dir)
|
||||
if result.AlbumName == "." || result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
func generateLibraryID(filePath string) string {
|
||||
return fmt.Sprintf("lib_%x", hashString(filePath))
|
||||
}
|
||||
|
||||
func hashString(s string) uint32 {
|
||||
var hash uint32 = 5381
|
||||
for _, c := range s {
|
||||
hash = ((hash << 5) + hash) + uint32(c)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func GetLibraryScanProgress() string {
|
||||
libraryScanProgressMu.RLock()
|
||||
defer libraryScanProgressMu.RUnlock()
|
||||
|
||||
jsonBytes, _ := json.Marshal(libraryScanProgress)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func CancelLibraryScan() {
|
||||
libraryScanCancelMu.Lock()
|
||||
defer libraryScanCancelMu.Unlock()
|
||||
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
libraryScanCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func ReadAudioMetadata(filePath string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
@@ -27,7 +27,6 @@ var (
|
||||
logBufferOnce sync.Once
|
||||
)
|
||||
|
||||
// GetLogBuffer returns the singleton log buffer instance
|
||||
func GetLogBuffer() *LogBuffer {
|
||||
logBufferOnce.Do(func() {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
@@ -45,7 +44,6 @@ func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||
lb.loggingEnabled = enabled
|
||||
}
|
||||
|
||||
// IsLoggingEnabled returns whether logging is enabled
|
||||
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
@@ -75,7 +73,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
fmt.Printf("[%s] %s\n", tag, message)
|
||||
}
|
||||
|
||||
// GetAll returns all log entries as JSON
|
||||
func (lb *LogBuffer) GetAll() string {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
@@ -99,21 +96,18 @@ func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||
return entries, len(lb.entries)
|
||||
}
|
||||
|
||||
// Clear clears all log entries
|
||||
func (lb *LogBuffer) Clear() {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.entries = lb.entries[:0]
|
||||
}
|
||||
|
||||
// Count returns the number of log entries
|
||||
func (lb *LogBuffer) Count() int {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return len(lb.entries)
|
||||
}
|
||||
|
||||
// Helper functions for logging with different levels
|
||||
func LogDebug(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -163,15 +157,10 @@ func GoLog(format string, args ...interface{}) {
|
||||
GetLogBuffer().Add(level, tag, message)
|
||||
}
|
||||
|
||||
// Exported functions for Flutter
|
||||
|
||||
// GetLogs returns all logs as JSON array
|
||||
func GetLogs() string {
|
||||
return GetLogBuffer().GetAll()
|
||||
}
|
||||
|
||||
// GetLogsSince returns logs since the given index
|
||||
// Returns JSON: {"logs": [...], "next_index": N}
|
||||
func GetLogsSince(index int) string {
|
||||
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||
logsJson, _ := json.Marshal(entries)
|
||||
@@ -179,17 +168,14 @@ func GetLogsSince(index int) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearLogs clears all logs
|
||||
func ClearLogs() {
|
||||
GetLogBuffer().Clear()
|
||||
}
|
||||
|
||||
// GetLogCount returns the number of log entries
|
||||
func GetLogCount() int {
|
||||
return GetLogBuffer().Count()
|
||||
}
|
||||
|
||||
// SetLoggingEnabled enables or disables logging from Flutter
|
||||
func SetLoggingEnabled(enabled bool) {
|
||||
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isFDOutput(outputFD int) bool {
|
||||
return outputFD > 0
|
||||
}
|
||||
|
||||
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
||||
if isFDOutput(outputFD) {
|
||||
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
|
||||
}
|
||||
return os.Create(outputPath)
|
||||
}
|
||||
|
||||
func cleanupOutputOnError(outputPath string, outputFD int) {
|
||||
if isFDOutput(outputFD) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(outputPath)
|
||||
if path == "" || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
+58
-8
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
type TrackIDCacheEntry struct {
|
||||
TidalTrackID int64
|
||||
QobuzTrackID int64
|
||||
AmazonTrackID string
|
||||
AmazonURL string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -106,7 +107,7 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -115,7 +116,7 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
entry = &TrackIDCacheEntry{}
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.AmazonTrackID = trackID
|
||||
entry.AmazonURL = amazonURL
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
@@ -156,17 +157,20 @@ func FetchCoverAndLyricsParallel(
|
||||
) *ParallelDownloadResult {
|
||||
result := &ParallelDownloadResult{}
|
||||
var wg sync.WaitGroup
|
||||
var resultMu sync.Mutex
|
||||
|
||||
if coverURL != "" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.CoverErr = err
|
||||
} else {
|
||||
result.CoverData = data
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -177,6 +181,7 @@ func FetchCoverAndLyricsParallel(
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.LyricsErr = err
|
||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
@@ -185,6 +190,7 @@ func FetchCoverAndLyricsParallel(
|
||||
} else {
|
||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -211,6 +217,9 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, req := range requests {
|
||||
if req.ISRC == "" {
|
||||
continue
|
||||
}
|
||||
if cached := cache.Get(req.ISRC); cached != nil {
|
||||
continue
|
||||
}
|
||||
@@ -225,7 +234,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
case "tidal":
|
||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||
case "qobuz":
|
||||
preWarmQobuzCache(r.ISRC)
|
||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||
case "amazon":
|
||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||
}
|
||||
@@ -243,10 +252,30 @@ func preWarmTidalCache(isrc, _, _ string) {
|
||||
}
|
||||
}
|
||||
|
||||
func preWarmQobuzCache(isrc string) {
|
||||
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
|
||||
// 1. From SongLink (fast, no Qobuz API call needed)
|
||||
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
||||
if spotifyID != "" {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||
// Parse QobuzID to int64
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, trackID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Direct ISRC search on Qobuz API
|
||||
downloader := NewQobuzDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||
}
|
||||
}
|
||||
@@ -254,13 +283,34 @@ func preWarmQobuzCache(isrc string) {
|
||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.Amazon {
|
||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||
if err == nil && availability != nil && availability.AmazonURL != "" {
|
||||
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
|
||||
}
|
||||
}
|
||||
|
||||
func PreWarmCache(tracksJSON string) error {
|
||||
var requests []PreWarmCacheRequest
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Service string `json:"service"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||
return fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||
}
|
||||
|
||||
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||
for i, t := range tracks {
|
||||
requests[i] = PreWarmCacheRequest{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
SpotifyID: t.SpotifyID,
|
||||
Service: t.Service,
|
||||
}
|
||||
}
|
||||
|
||||
go PreWarmTrackCache(requests)
|
||||
return nil
|
||||
|
||||
@@ -187,13 +187,13 @@ type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
itemID string
|
||||
current int64
|
||||
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||
startTime time.Time // Track start time for speed calculation
|
||||
lastTime time.Time // Track last update time for speed calculation
|
||||
lastBytes int64 // Track bytes at last speed calculation
|
||||
lastReported int64
|
||||
startTime time.Time
|
||||
lastTime time.Time
|
||||
lastBytes int64
|
||||
}
|
||||
|
||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||
const progressUpdateThreshold = 64 * 1024
|
||||
|
||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||
now := time.Now()
|
||||
|
||||
+239
-100
@@ -380,6 +380,42 @@ func decodeXOR(data []byte) string {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return "", fmt.Errorf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
if success, ok := raw["success"].(bool); ok && !success {
|
||||
if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
return "", fmt.Errorf("api returned success=false")
|
||||
}
|
||||
|
||||
if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||
return strings.TrimSpace(linkVal), nil
|
||||
}
|
||||
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||
return strings.TrimSpace(linkVal), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||
formatID := mapJumoQuality(quality)
|
||||
region := "US"
|
||||
@@ -725,75 +761,126 @@ type qobuzAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// Qobuz API timeout configuration
|
||||
// Mobile networks are more unstable, so we use longer timeouts
|
||||
const (
|
||||
qobuzAPITimeoutDesktop = 15 * time.Second
|
||||
qobuzAPITimeoutMobile = 25 * time.Second
|
||||
qobuzMaxRetries = 2 // Number of retries per API
|
||||
qobuzRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// getQobuzAPITimeout returns appropriate timeout based on platform
|
||||
// For mobile (gomobile builds), we use longer timeouts
|
||||
func getQobuzAPITimeout() time.Duration {
|
||||
// Since this runs in gomobile context, we always use mobile timeout
|
||||
// The Go backend is only used on mobile (Android/iOS)
|
||||
return qobuzAPITimeoutMobile
|
||||
}
|
||||
|
||||
// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic
|
||||
func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) {
|
||||
var lastErr error
|
||||
retryDelay := qobuzRetryDelay
|
||||
|
||||
for attempt := 0; attempt <= qobuzMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2 // Exponential backoff
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(timeout)
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Check for retryable errors (timeout, connection reset)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "eof") {
|
||||
continue // Retry
|
||||
}
|
||||
break // Non-retryable error
|
||||
}
|
||||
// Server errors are retryable
|
||||
if resp.StatusCode >= 500 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
// 429 rate limit - wait and retry
|
||||
if resp.StatusCode == 429 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("rate limited")
|
||||
retryDelay = 2 * time.Second // Wait longer for rate limit
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
return "", fmt.Errorf("received HTML instead of JSON")
|
||||
}
|
||||
|
||||
urlVal, parseErr := extractQobuzDownloadURLFromBody(body)
|
||||
if parseErr == nil {
|
||||
return urlVal, nil
|
||||
}
|
||||
lastErr = parseErr
|
||||
continue
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return "", fmt.Errorf("all retries failed")
|
||||
}
|
||||
|
||||
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
||||
|
||||
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
timeout := getQobuzAPITimeout()
|
||||
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
||||
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout)
|
||||
resultChan <- qobuzAPIResult{
|
||||
apiURL: api,
|
||||
downloadURL: downloadURL,
|
||||
err: err,
|
||||
duration: time.Since(reqStart),
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var errorResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
if result.URL != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
@@ -860,7 +947,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
@@ -897,7 +984,7 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -916,23 +1003,23 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
@@ -950,13 +1037,17 @@ type QobuzDownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
@@ -964,6 +1055,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
var track *QobuzTrack
|
||||
var err error
|
||||
|
||||
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
|
||||
if req.QobuzID != "" {
|
||||
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
||||
var trackID int64
|
||||
@@ -978,17 +1070,43 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Use cached Qobuz Track ID (fast, no search needed)
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||
track, err = downloader.GetTrackByID(cached.QobuzTrackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||
GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
|
||||
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
|
||||
GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||
songLinkClient := NewSongLinkClient()
|
||||
availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if slErr == nil && availability != nil && availability.QobuzID != "" {
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID)
|
||||
track, err = downloader.GetTrackByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err)
|
||||
track = nil
|
||||
} else if track != nil {
|
||||
GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
|
||||
// Cache for future use
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: ISRC search with duration verification
|
||||
if track == nil && req.ISRC != "" {
|
||||
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||
@@ -1005,7 +1123,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
|
||||
if track == nil {
|
||||
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
@@ -1035,11 +1155,18 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
qobuzQuality := "27"
|
||||
@@ -1077,7 +1204,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
)
|
||||
}()
|
||||
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
@@ -1122,39 +1249,50 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
if isSafOutput {
|
||||
GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
|
||||
return QobuzDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: actualBitDepth,
|
||||
@@ -1166,5 +1304,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||
t.Run("reads nested data.url", func(t *testing.T) {
|
||||
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/audio.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reads top-level url", func(t *testing.T) {
|
||||
body := []byte(`{"url":"https://example.test/top.flac"}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/top.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns API error", func(t *testing.T) {
|
||||
body := []byte(`{"error":"track not found"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "track not found" {
|
||||
t.Fatalf("expected track-not-found error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns message when success false", func(t *testing.T) {
|
||||
body := []byte(`{"success":false,"message":"blocked"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "blocked" {
|
||||
t.Fatalf("expected blocked error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
+191
-35
@@ -8,7 +8,6 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SongLinkClient struct {
|
||||
@@ -26,6 +25,8 @@ type TrackAvailability struct {
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -98,6 +99,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
@@ -111,6 +113,12 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -131,40 +139,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func checkQobuzAvailability(isrc string) bool {
|
||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||
appID := "798273057"
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return false
|
||||
}
|
||||
|
||||
var searchResp struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return searchResp.Tracks.Total > 0
|
||||
}
|
||||
|
||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||
func extractDeezerIDFromURL(deezerURL string) string {
|
||||
parts := strings.Split(deezerURL, "/")
|
||||
@@ -178,6 +152,102 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
||||
// URL formats:
|
||||
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||
// - https://open.qobuz.com/track/12345678
|
||||
// - https://www.qobuz.com/track/12345678
|
||||
// - https://play.qobuz.com/track/12345678
|
||||
func extractQobuzIDFromURL(qobuzURL string) string {
|
||||
if qobuzURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try to find /track/ID pattern first
|
||||
if strings.Contains(qobuzURL, "/track/") {
|
||||
parts := strings.Split(qobuzURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
// Remove trailing slash or path
|
||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
// Validate it's a number
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from album URL with track highlight
|
||||
// Format: /album/albumname/trackid or ?trackId=12345678
|
||||
if strings.Contains(qobuzURL, "trackId=") {
|
||||
parts := strings.Split(qobuzURL, "trackId=")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: get last numeric segment from URL
|
||||
parts := strings.Split(qobuzURL, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(part, "?"); idx > 0 {
|
||||
part = part[:idx]
|
||||
}
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" && isNumeric(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTidalIDFromURL extracts Tidal track ID from URL
|
||||
// URL formats:
|
||||
// - https://tidal.com/browse/track/12345678
|
||||
// - https://listen.tidal.com/track/12345678
|
||||
func extractTidalIDFromURL(tidalURL string) string {
|
||||
if tidalURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.Contains(tidalURL, "/track/") {
|
||||
parts := strings.Split(tidalURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isNumeric is defined in library_scan.go
|
||||
|
||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -353,6 +423,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
@@ -360,6 +431,12 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
}
|
||||
@@ -431,6 +508,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
@@ -438,6 +516,12 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
@@ -499,3 +583,75 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
|
||||
|
||||
return availability.AmazonURL, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on SongLink")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
EntityID string `json:"entityUniqueId"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
+10
-13
@@ -63,7 +63,6 @@ var (
|
||||
credentialsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
@@ -141,7 +140,7 @@ type TrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumTrackMetadata struct {
|
||||
@@ -210,7 +209,7 @@ type ArtistAlbumMetadata struct {
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Images string `json:"images"`
|
||||
AlbumType string `json:"album_type"` // album, single, compilation
|
||||
AlbumType string `json:"album_type"`
|
||||
Artists string `json:"artists"`
|
||||
}
|
||||
|
||||
@@ -532,7 +531,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
|
||||
albumImage := firstImageURL(data.Images)
|
||||
|
||||
// Get first artist ID
|
||||
var firstArtistId string
|
||||
if len(data.Artists) > 0 {
|
||||
firstArtistId = data.Artists[0].ID
|
||||
@@ -565,7 +563,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
|
||||
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||
|
||||
// Collect track IDs for parallel ISRC fetching
|
||||
trackIDs := make([]string, len(allTrackItems))
|
||||
for i, item := range allTrackItems {
|
||||
trackIDs[i] = item.ID
|
||||
@@ -937,14 +934,14 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
||||
defer c.rngMu.Unlock()
|
||||
|
||||
macMajor := c.rng.Intn(4) + 11
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||
macMinor := c.rng.Intn(5) + 4
|
||||
webkitMajor := c.rng.Intn(7) + 530
|
||||
webkitMinor := c.rng.Intn(7) + 30
|
||||
chromeMajor := c.rng.Intn(25) + 80
|
||||
chromeBuild := c.rng.Intn(1500) + 3000
|
||||
chromePatch := c.rng.Intn(65) + 60
|
||||
safariMajor := c.rng.Intn(7) + 530
|
||||
safariMinor := c.rng.Intn(6) + 30
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
|
||||
+326
-166
@@ -317,7 +317,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find exact ISRC match
|
||||
for i := range result.Items {
|
||||
if result.Items[i].ISRC == isrc {
|
||||
return &result.Items[i], nil
|
||||
@@ -341,7 +340,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
// Build search queries - multiple strategies (same as PC version)
|
||||
queries := []string{}
|
||||
|
||||
// Strategy 1: Artist + Track name (original)
|
||||
if artistName != "" && trackName != "" {
|
||||
queries = append(queries, artistName+" "+trackName)
|
||||
}
|
||||
@@ -584,13 +582,123 @@ type tidalAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
// Tidal API timeout configuration
|
||||
// Mobile networks are more unstable, so we use longer timeouts
|
||||
const (
|
||||
tidalAPITimeoutMobile = 25 * time.Second
|
||||
tidalMaxRetries = 2 // Number of retries per API
|
||||
tidalRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic
|
||||
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
|
||||
var lastErr error
|
||||
retryDelay := tidalRetryDelay
|
||||
|
||||
for attempt := 0; attempt <= tidalMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2 // Exponential backoff
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(timeout)
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Check for retryable errors (timeout, connection reset)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "eof") {
|
||||
continue // Retry
|
||||
}
|
||||
break // Non-retryable error
|
||||
}
|
||||
// Server errors are retryable
|
||||
if resp.StatusCode >= 500 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
// 429 rate limit - wait and retry
|
||||
if resp.StatusCode == 429 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("rate limited")
|
||||
retryDelay = 2 * time.Second // Wait longer for rate limit
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return TidalDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Try V2 response format (with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
return TidalDownloadInfo{}, fmt.Errorf("returned PREVIEW instead of FULL")
|
||||
}
|
||||
|
||||
return TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try V1 response format
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
return TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TidalDownloadInfo{}, fmt.Errorf("no download URL or manifest in response")
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return TidalDownloadInfo{}, lastErr
|
||||
}
|
||||
return TidalDownloadInfo{}, fmt.Errorf("all retries failed")
|
||||
}
|
||||
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
||||
|
||||
resultChan := make(chan tidalAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
@@ -598,69 +706,13 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
||||
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
info, err := fetchTidalURLWithRetry(api, trackID, quality, tidalAPITimeoutMobile)
|
||||
resultChan <- tidalAPIResult{
|
||||
apiURL: api,
|
||||
info: info,
|
||||
err: err,
|
||||
duration: time.Since(reqStart),
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
info := TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
info := TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
@@ -787,6 +839,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||
}
|
||||
|
||||
if segmentCount == 0 {
|
||||
return "", "", nil, fmt.Errorf("no segments found in manifest")
|
||||
}
|
||||
|
||||
for i := 1; i <= segmentCount; i++ {
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
@@ -795,7 +851,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
return "", initURL, mediaURLs, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||
@@ -808,7 +864,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, outputFD, itemID)
|
||||
}
|
||||
|
||||
if itemID != "" {
|
||||
@@ -845,7 +901,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -864,30 +920,30 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
|
||||
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath string, outputFD int, itemID string) error {
|
||||
fmt.Println("[Tidal] Parsing manifest...")
|
||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
@@ -932,7 +988,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
@@ -948,19 +1004,19 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
@@ -968,17 +1024,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
}
|
||||
|
||||
// For DASH format, determine correct M4A path
|
||||
// If outputPath already ends with .m4a, use it directly
|
||||
// Otherwise, convert .flac to .m4a
|
||||
// If outputPath already ends with .m4a, use it directly.
|
||||
// If outputPath ends with .flac, convert .flac to .m4a.
|
||||
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
|
||||
var m4aPath string
|
||||
if strings.HasSuffix(outputPath, ".m4a") {
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
} else if strings.HasSuffix(outputPath, ".flac") {
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
} else {
|
||||
m4aPath = outputPath
|
||||
}
|
||||
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||
|
||||
out, err := os.Create(m4aPath)
|
||||
out, err := openOutputForWrite(m4aPath, outputFD)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
||||
return fmt.Errorf("failed to create M4A file: %w", err)
|
||||
@@ -987,20 +1046,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
GoLog("[Tidal] Downloading init segment...\n")
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Init segment request failed: %v\n", err)
|
||||
return fmt.Errorf("failed to create init segment request: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1010,7 +1069,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
@@ -1018,7 +1077,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1030,7 +1089,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
for i, mediaURL := range mediaURLs {
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
@@ -1046,14 +1105,14 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1063,7 +1122,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||
}
|
||||
@@ -1071,7 +1130,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
@@ -1081,7 +1140,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(m4aPath)
|
||||
cleanupOutputOnError(m4aPath, outputFD)
|
||||
GoLog("[Tidal] Failed to close M4A file: %v\n", err)
|
||||
return fmt.Errorf("failed to close M4A file: %w", err)
|
||||
}
|
||||
@@ -1350,8 +1409,11 @@ func isLatinScript(s string) bool {
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
@@ -1407,49 +1469,83 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
if track == nil && req.SpotifyID != "" {
|
||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
var tidalURL string
|
||||
var slErr error
|
||||
|
||||
var trackID int64
|
||||
var gotTidalID bool
|
||||
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID)
|
||||
availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
if slErr == nil && availability != nil && availability.TidalID != "" {
|
||||
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
// Fallback to URL parsing if TidalID not in struct
|
||||
if !gotTidalID && availability != nil && availability.TidalURL != "" {
|
||||
var idErr error
|
||||
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
||||
if idErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if slErr == nil && availability != nil && availability.TidalID != "" {
|
||||
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
// Fallback to URL parsing if TidalID not in struct
|
||||
if !gotTidalID && availability != nil && availability.TidalURL != "" {
|
||||
var idErr error
|
||||
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
||||
if idErr == nil && trackID > 0 {
|
||||
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
|
||||
gotTidalID = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if slErr == nil && tidalURL != "" {
|
||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||
if idErr == nil {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
if gotTidalID && trackID > 0 {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff > 3 {
|
||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
track = nil // Reject this match
|
||||
}
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff > 3 {
|
||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
track = nil // Reject this match
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for future use
|
||||
if track != nil && req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1516,31 +1612,52 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
var outputPath string
|
||||
var m4aPath string
|
||||
if quality == "HIGH" {
|
||||
filename = sanitizeFilename(filename) + ".m4a"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
outputExt := strings.TrimSpace(req.OutputExt)
|
||||
if outputExt == "" {
|
||||
if quality == "HIGH" {
|
||||
outputExt = ".m4a"
|
||||
} else {
|
||||
outputExt = ".flac"
|
||||
}
|
||||
} else if !strings.HasPrefix(outputExt, ".") {
|
||||
outputExt = "." + outputExt
|
||||
}
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
if quality != "HIGH" {
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
var outputPath string
|
||||
var m4aPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
if outputExt == ".m4a" || quality == "HIGH" {
|
||||
filename = sanitizeFilename(filename) + ".m4a"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
}
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
if quality != "HIGH" {
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
if _, err := os.Stat(tmpPath); err == nil {
|
||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
os.Remove(tmpPath)
|
||||
if !isSafOutput {
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
if _, err := os.Stat(tmpPath); err == nil {
|
||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||
@@ -1575,7 +1692,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return "Direct URL"
|
||||
}())
|
||||
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return TidalDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
@@ -1592,11 +1709,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
|
||||
actualOutputPath := outputPath
|
||||
if _, err := os.Stat(m4aPath); err == nil {
|
||||
actualOutputPath = m4aPath
|
||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||
if !isSafOutput {
|
||||
if _, err := os.Stat(m4aPath); err == nil {
|
||||
actualOutputPath = m4aPath
|
||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||
}
|
||||
}
|
||||
|
||||
releaseDate := req.ReleaseDate
|
||||
@@ -1635,7 +1754,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if strings.HasSuffix(actualOutputPath, ".flac") {
|
||||
actualExt := outputExt
|
||||
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
||||
actualExt = ".m4a"
|
||||
}
|
||||
if actualExt == "" && !isSafOutput {
|
||||
actualExt = strings.ToLower(filepath.Ext(actualOutputPath))
|
||||
}
|
||||
|
||||
if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
@@ -1646,7 +1773,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||
GoLog("[Tidal] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
@@ -1666,7 +1793,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
||||
if quality == "HIGH" {
|
||||
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
||||
|
||||
@@ -1676,7 +1803,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
@@ -1690,7 +1817,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
}
|
||||
|
||||
bitDepth := downloadInfo.BitDepth
|
||||
sampleRate := downloadInfo.SampleRate
|
||||
@@ -1698,15 +1827,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
if quality == "HIGH" {
|
||||
bitDepth = 0
|
||||
sampleRate = 44100
|
||||
if parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return TidalDownloadResult{
|
||||
@@ -1723,3 +1846,40 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseTidalURL(input string) (string, string, error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return "", "", fmt.Errorf("empty URL")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if parsed.Host != "tidal.com" && parsed.Host != "listen.tidal.com" && parsed.Host != "www.tidal.com" {
|
||||
return "", "", fmt.Errorf("not a Tidal URL")
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
|
||||
// Handle /browse/track/123 format
|
||||
if len(parts) > 0 && parts[0] == "browse" {
|
||||
parts = parts[1:]
|
||||
}
|
||||
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("invalid Tidal URL format")
|
||||
}
|
||||
|
||||
resourceType := parts[0]
|
||||
resourceID := parts[1]
|
||||
|
||||
switch resourceType {
|
||||
case "track", "album", "artist", "playlist":
|
||||
return resourceType, resourceID, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,20 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseTidalUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseTidalURLExport(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "convertTidalToSpotifyDeezer":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendConvertTidalToSpotifyDeezer(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchDeezerByISRC":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let isrc = args["isrc"] as! String
|
||||
@@ -625,6 +639,14 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "runPostProcessingV2":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let inputJson = args["input"] as? String ?? ""
|
||||
let metadataJson = args["metadata"] as? String ?? ""
|
||||
let response = GobackendRunPostProcessingV2JSON(inputJson, metadataJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getPostProcessingProviders":
|
||||
let response = GobackendGetPostProcessingProvidersJSON(&error)
|
||||
@@ -687,6 +709,35 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Local Library Scanning
|
||||
case "setLibraryCoverCacheDir":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let cacheDir = args["cache_dir"] as! String
|
||||
GobackendSetLibraryCoverCacheDirJSON(cacheDir)
|
||||
return nil
|
||||
|
||||
case "scanLibraryFolder":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let folderPath = args["folder_path"] as! String
|
||||
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getLibraryScanProgress":
|
||||
let response = GobackendGetLibraryScanProgressJSON()
|
||||
return response
|
||||
|
||||
case "cancelLibraryScan":
|
||||
GobackendCancelLibraryScanJSON()
|
||||
return nil
|
||||
|
||||
case "readAudioMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
+25
-1
@@ -67,7 +67,7 @@
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<!-- File Sharing - Allow access via Files app -->
|
||||
@@ -81,5 +81,29 @@
|
||||
<!-- Photo Library (for cover art if needed) -->
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>SpotiFLAC needs access to save album artwork</string>
|
||||
|
||||
<!-- URL Schemes for deep linking -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.zarz.spotiflac</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>spotiflac</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<!-- Associated Domains for Universal Links -->
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>spotify</string>
|
||||
<string>deezer</string>
|
||||
<string>tidal</string>
|
||||
<string>youtube-music</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.3.5';
|
||||
static const String buildNumber = '70';
|
||||
static const String version = '3.5.0-beta.1';
|
||||
static const String buildNumber = '73';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -142,7 +142,13 @@ abstract class AppLocalizations {
|
||||
/// **'Home'**
|
||||
String get navHome;
|
||||
|
||||
/// Bottom navigation - History tab
|
||||
/// Bottom navigation - Library tab
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Library'**
|
||||
String get navLibrary;
|
||||
|
||||
/// Bottom navigation - History tab (legacy)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'History'**
|
||||
@@ -1762,6 +1768,12 @@ abstract class AppLocalizations {
|
||||
/// **'\"{trackName}\" already downloaded'**
|
||||
String snackbarAlreadyDownloaded(String trackName);
|
||||
|
||||
/// Snackbar - track already exists in local library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'\"{trackName}\" already exists in your library'**
|
||||
String snackbarAlreadyInLibrary(String trackName);
|
||||
|
||||
/// Snackbar - history deleted
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3688,6 +3700,30 @@ abstract class AppLocalizations {
|
||||
/// **'Save failed downloads to TXT file automatically'**
|
||||
String get settingsAutoExportFailedSubtitle;
|
||||
|
||||
/// Setting for network type preference
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download Network'**
|
||||
String get settingsDownloadNetwork;
|
||||
|
||||
/// Network option - use any connection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'WiFi + Mobile Data'**
|
||||
String get settingsDownloadNetworkAny;
|
||||
|
||||
/// Network option - only use WiFi
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'WiFi Only'**
|
||||
String get settingsDownloadNetworkWifiOnly;
|
||||
|
||||
/// Subtitle explaining network preference
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'**
|
||||
String get settingsDownloadNetworkSubtitle;
|
||||
|
||||
/// Empty queue state title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4041,6 +4077,348 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'All Files Access disabled. The app will use limited storage access.'**
|
||||
String get allFilesAccessDisabledMessage;
|
||||
|
||||
/// Settings menu item - local library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Local Library'**
|
||||
String get settingsLocalLibrary;
|
||||
|
||||
/// Subtitle for local library settings
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan music & detect duplicates'**
|
||||
String get settingsLocalLibrarySubtitle;
|
||||
|
||||
/// Library settings page title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Local Library'**
|
||||
String get libraryTitle;
|
||||
|
||||
/// Section header for library status
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Library Status'**
|
||||
String get libraryStatus;
|
||||
|
||||
/// Section header for scan settings
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan Settings'**
|
||||
String get libraryScanSettings;
|
||||
|
||||
/// Toggle to enable library scanning
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable Local Library'**
|
||||
String get libraryEnableLocalLibrary;
|
||||
|
||||
/// Subtitle for enable toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan and track your existing music'**
|
||||
String get libraryEnableLocalLibrarySubtitle;
|
||||
|
||||
/// Folder selection setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Library Folder'**
|
||||
String get libraryFolder;
|
||||
|
||||
/// Placeholder when no folder selected
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap to select folder'**
|
||||
String get libraryFolderHint;
|
||||
|
||||
/// Toggle for duplicate indicator in search
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show Duplicate Indicator'**
|
||||
String get libraryShowDuplicateIndicator;
|
||||
|
||||
/// Subtitle for duplicate indicator toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show when searching for existing tracks'**
|
||||
String get libraryShowDuplicateIndicatorSubtitle;
|
||||
|
||||
/// Section header for library actions
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Actions'**
|
||||
String get libraryActions;
|
||||
|
||||
/// Button to start library scan
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan Library'**
|
||||
String get libraryScan;
|
||||
|
||||
/// Subtitle for scan button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan for audio files'**
|
||||
String get libraryScanSubtitle;
|
||||
|
||||
/// Message when trying to scan without folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select a folder first'**
|
||||
String get libraryScanSelectFolderFirst;
|
||||
|
||||
/// Button to remove entries for missing files
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cleanup Missing Files'**
|
||||
String get libraryCleanupMissingFiles;
|
||||
|
||||
/// Subtitle for cleanup button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove entries for files that no longer exist'**
|
||||
String get libraryCleanupMissingFilesSubtitle;
|
||||
|
||||
/// Button to clear all library entries
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear Library'**
|
||||
String get libraryClear;
|
||||
|
||||
/// Subtitle for clear button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove all scanned tracks'**
|
||||
String get libraryClearSubtitle;
|
||||
|
||||
/// Dialog title for clear confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear Library'**
|
||||
String get libraryClearConfirmTitle;
|
||||
|
||||
/// Dialog message for clear confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'**
|
||||
String get libraryClearConfirmMessage;
|
||||
|
||||
/// Section header for about info
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About Local Library'**
|
||||
String get libraryAbout;
|
||||
|
||||
/// Description of local library feature
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'**
|
||||
String get libraryAboutDescription;
|
||||
|
||||
/// Track count in library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks'**
|
||||
String libraryTracksCount(int count);
|
||||
|
||||
/// Last scan time display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last scanned: {time}'**
|
||||
String libraryLastScanned(String time);
|
||||
|
||||
/// Shown when library has never been scanned
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Never'**
|
||||
String get libraryLastScannedNever;
|
||||
|
||||
/// Status during scan
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scanning...'**
|
||||
String get libraryScanning;
|
||||
|
||||
/// Scan progress display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{progress}% of {total} files'**
|
||||
String libraryScanProgress(String progress, int total);
|
||||
|
||||
/// Badge shown on tracks that exist in local library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'In Library'**
|
||||
String get libraryInLibrary;
|
||||
|
||||
/// Snackbar after cleanup
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Removed {count} missing files from library'**
|
||||
String libraryRemovedMissingFiles(int count);
|
||||
|
||||
/// Snackbar after clearing library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Library cleared'**
|
||||
String get libraryCleared;
|
||||
|
||||
/// Dialog title for storage permission
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Storage Access Required'**
|
||||
String get libraryStorageAccessRequired;
|
||||
|
||||
/// Dialog message for storage permission
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.'**
|
||||
String get libraryStorageAccessMessage;
|
||||
|
||||
/// Error when folder doesn't exist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Selected folder does not exist'**
|
||||
String get libraryFolderNotExist;
|
||||
|
||||
/// Badge for tracks downloaded via SpotiFLAC
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloaded'**
|
||||
String get librarySourceDownloaded;
|
||||
|
||||
/// Badge for tracks from local library scan
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Local'**
|
||||
String get librarySourceLocal;
|
||||
|
||||
/// Filter chip - show all library items
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All'**
|
||||
String get libraryFilterAll;
|
||||
|
||||
/// Filter chip - show only downloaded items
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloaded'**
|
||||
String get libraryFilterDownloaded;
|
||||
|
||||
/// Filter chip - show only local library items
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Local'**
|
||||
String get libraryFilterLocal;
|
||||
|
||||
/// Filter bottom sheet title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filters'**
|
||||
String get libraryFilterTitle;
|
||||
|
||||
/// Reset all filters button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset'**
|
||||
String get libraryFilterReset;
|
||||
|
||||
/// Apply filters button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply'**
|
||||
String get libraryFilterApply;
|
||||
|
||||
/// Filter section - source type
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Source'**
|
||||
String get libraryFilterSource;
|
||||
|
||||
/// Filter section - audio quality
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Quality'**
|
||||
String get libraryFilterQuality;
|
||||
|
||||
/// Filter option - high resolution audio
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hi-Res (24bit)'**
|
||||
String get libraryFilterQualityHiRes;
|
||||
|
||||
/// Filter option - CD quality audio
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'CD (16bit)'**
|
||||
String get libraryFilterQualityCD;
|
||||
|
||||
/// Filter option - lossy compressed audio
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy'**
|
||||
String get libraryFilterQualityLossy;
|
||||
|
||||
/// Filter section - file format
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Format'**
|
||||
String get libraryFilterFormat;
|
||||
|
||||
/// Filter section - date range
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Date Added'**
|
||||
String get libraryFilterDate;
|
||||
|
||||
/// Filter option - today only
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Today'**
|
||||
String get libraryFilterDateToday;
|
||||
|
||||
/// Filter option - this week
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This Week'**
|
||||
String get libraryFilterDateWeek;
|
||||
|
||||
/// Filter option - this month
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This Month'**
|
||||
String get libraryFilterDateMonth;
|
||||
|
||||
/// Filter option - this year
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This Year'**
|
||||
String get libraryFilterDateYear;
|
||||
|
||||
/// Badge showing number of active filters
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} filter(s) active'**
|
||||
String libraryFilterActive(int count);
|
||||
|
||||
/// Relative time - less than a minute ago
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Just now'**
|
||||
String get timeJustNow;
|
||||
|
||||
/// Relative time - minutes ago
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 minute ago} other{{count} minutes ago}}'**
|
||||
String timeMinutesAgo(int count);
|
||||
|
||||
/// Relative time - hours ago
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'**
|
||||
String timeHoursAgo(int count);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Startseite';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'Verlauf';
|
||||
|
||||
@@ -958,6 +961,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -2025,6 +2033,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2247,4 +2268,207 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -943,6 +946,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -2010,6 +2018,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2232,4 +2253,207 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -943,6 +946,11 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -2010,6 +2018,19 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2232,6 +2253,209 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -943,6 +946,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -2010,6 +2018,19 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2232,4 +2253,207 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'होम';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'इतिहास';
|
||||
|
||||
@@ -943,6 +946,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -2010,6 +2018,19 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2232,4 +2253,207 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Beranda';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'Riwayat';
|
||||
|
||||
@@ -949,6 +952,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '\"$trackName\" sudah diunduh';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'Riwayat dihapus';
|
||||
|
||||
@@ -2023,6 +2031,19 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Tidak ada unduhan dalam antrian';
|
||||
|
||||
@@ -2245,4 +2266,207 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'ホーム';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => '履歴';
|
||||
|
||||
@@ -938,6 +941,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '「$trackName」は既にダウンロードされています';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => '履歴を消去しました';
|
||||
|
||||
@@ -1997,6 +2005,19 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'キューにダウンロードがありません';
|
||||
|
||||
@@ -2218,4 +2239,207 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -943,6 +946,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -2010,6 +2018,19 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2232,4 +2253,207 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -943,6 +946,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -2010,6 +2018,19 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2232,4 +2253,207 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -943,6 +946,11 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -2010,6 +2018,19 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2232,6 +2253,209 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Главная';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'История';
|
||||
|
||||
@@ -74,9 +77,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -87,9 +90,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count альбомов',
|
||||
one: '1 альбом',
|
||||
many: '$count альбомов',
|
||||
few: '$count альбома',
|
||||
one: '$count альбом',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -514,9 +517,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -548,9 +551,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count релизов',
|
||||
one: '1 релиз',
|
||||
many: '$count релизов',
|
||||
few: '$count релиза',
|
||||
one: '$count релиз',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -930,9 +933,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -965,6 +968,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '\"$trackName\" уже скачан';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'История очищена';
|
||||
|
||||
@@ -980,9 +988,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалено $count $_temp0';
|
||||
}
|
||||
@@ -1129,9 +1137,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -1573,9 +1581,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -2049,6 +2057,19 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Нет загрузок в очереди';
|
||||
|
||||
@@ -2116,9 +2137,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -2148,9 +2169,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -2278,4 +2299,207 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Ara';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'Geçmiş';
|
||||
|
||||
@@ -950,6 +953,11 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return '\"$trackName\" zaten indirilmiş';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'Geçmiş temizlendi';
|
||||
|
||||
@@ -2025,6 +2033,19 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2247,4 +2268,207 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
|
||||
@@ -943,6 +946,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '\"$trackName\" already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarHistoryCleared => 'History cleared';
|
||||
|
||||
@@ -2010,6 +2018,19 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2232,6 +2253,209 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String timeHoursAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
|
||||
+174
-2
@@ -9,8 +9,10 @@
|
||||
|
||||
"navHome": "Home",
|
||||
"@navHome": {"description": "Bottom navigation - Home tab"},
|
||||
"navLibrary": "Library",
|
||||
"@navLibrary": {"description": "Bottom navigation - Library tab"},
|
||||
"navHistory": "History",
|
||||
"@navHistory": {"description": "Bottom navigation - History tab"},
|
||||
"@navHistory": {"description": "Bottom navigation - History tab (legacy)"},
|
||||
"navSettings": "Settings",
|
||||
"@navSettings": {"description": "Bottom navigation - Settings tab"},
|
||||
"navStore": "Store",
|
||||
@@ -670,6 +672,13 @@
|
||||
"trackName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library",
|
||||
"@snackbarAlreadyInLibrary": {
|
||||
"description": "Snackbar - track already exists in local library",
|
||||
"placeholders": {
|
||||
"trackName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"snackbarHistoryCleared": "History cleared",
|
||||
"@snackbarHistoryCleared": {"description": "Snackbar - history deleted"},
|
||||
"snackbarCredentialsSaved": "Credentials saved",
|
||||
@@ -1476,6 +1485,16 @@
|
||||
"@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"},
|
||||
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
|
||||
"@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"},
|
||||
|
||||
"settingsDownloadNetwork": "Download Network",
|
||||
"@settingsDownloadNetwork": {"description": "Setting for network type preference"},
|
||||
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
|
||||
"@settingsDownloadNetworkAny": {"description": "Network option - use any connection"},
|
||||
"settingsDownloadNetworkWifiOnly": "WiFi Only",
|
||||
"@settingsDownloadNetworkWifiOnly": {"description": "Network option - only use WiFi"},
|
||||
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
|
||||
"@settingsDownloadNetworkSubtitle": {"description": "Subtitle explaining network preference"},
|
||||
|
||||
"queueEmpty": "No downloads in queue",
|
||||
"@queueEmpty": {"description": "Empty queue state title"},
|
||||
"queueEmptySubtitle": "Add tracks from the home screen",
|
||||
@@ -1675,5 +1694,158 @@
|
||||
"allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.",
|
||||
"@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"},
|
||||
"allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.",
|
||||
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"}
|
||||
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"},
|
||||
|
||||
"settingsLocalLibrary": "Local Library",
|
||||
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
|
||||
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
||||
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
|
||||
"libraryTitle": "Local Library",
|
||||
"@libraryTitle": {"description": "Library settings page title"},
|
||||
"libraryStatus": "Library Status",
|
||||
"@libraryStatus": {"description": "Section header for library status"},
|
||||
"libraryScanSettings": "Scan Settings",
|
||||
"@libraryScanSettings": {"description": "Section header for scan settings"},
|
||||
"libraryEnableLocalLibrary": "Enable Local Library",
|
||||
"@libraryEnableLocalLibrary": {"description": "Toggle to enable library scanning"},
|
||||
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
|
||||
"@libraryEnableLocalLibrarySubtitle": {"description": "Subtitle for enable toggle"},
|
||||
"libraryFolder": "Library Folder",
|
||||
"@libraryFolder": {"description": "Folder selection setting"},
|
||||
"libraryFolderHint": "Tap to select folder",
|
||||
"@libraryFolderHint": {"description": "Placeholder when no folder selected"},
|
||||
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
|
||||
"@libraryShowDuplicateIndicator": {"description": "Toggle for duplicate indicator in search"},
|
||||
"libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks",
|
||||
"@libraryShowDuplicateIndicatorSubtitle": {"description": "Subtitle for duplicate indicator toggle"},
|
||||
"libraryActions": "Actions",
|
||||
"@libraryActions": {"description": "Section header for library actions"},
|
||||
"libraryScan": "Scan Library",
|
||||
"@libraryScan": {"description": "Button to start library scan"},
|
||||
"libraryScanSubtitle": "Scan for audio files",
|
||||
"@libraryScanSubtitle": {"description": "Subtitle for scan button"},
|
||||
"libraryScanSelectFolderFirst": "Select a folder first",
|
||||
"@libraryScanSelectFolderFirst": {"description": "Message when trying to scan without folder"},
|
||||
"libraryCleanupMissingFiles": "Cleanup Missing Files",
|
||||
"@libraryCleanupMissingFiles": {"description": "Button to remove entries for missing files"},
|
||||
"libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist",
|
||||
"@libraryCleanupMissingFilesSubtitle": {"description": "Subtitle for cleanup button"},
|
||||
"libraryClear": "Clear Library",
|
||||
"@libraryClear": {"description": "Button to clear all library entries"},
|
||||
"libraryClearSubtitle": "Remove all scanned tracks",
|
||||
"@libraryClearSubtitle": {"description": "Subtitle for clear button"},
|
||||
"libraryClearConfirmTitle": "Clear Library",
|
||||
"@libraryClearConfirmTitle": {"description": "Dialog title for clear confirmation"},
|
||||
"libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.",
|
||||
"@libraryClearConfirmMessage": {"description": "Dialog message for clear confirmation"},
|
||||
"libraryAbout": "About Local Library",
|
||||
"@libraryAbout": {"description": "Section header for about info"},
|
||||
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
|
||||
"@libraryAboutDescription": {"description": "Description of local library feature"},
|
||||
"libraryTracksCount": "{count} tracks",
|
||||
"@libraryTracksCount": {
|
||||
"description": "Track count in library",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
"placeholders": {
|
||||
"time": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"libraryLastScannedNever": "Never",
|
||||
"@libraryLastScannedNever": {"description": "Shown when library has never been scanned"},
|
||||
"libraryScanning": "Scanning...",
|
||||
"@libraryScanning": {"description": "Status during scan"},
|
||||
"libraryScanProgress": "{progress}% of {total} files",
|
||||
"@libraryScanProgress": {
|
||||
"description": "Scan progress display",
|
||||
"placeholders": {
|
||||
"progress": {"type": "String"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryInLibrary": "In Library",
|
||||
"@libraryInLibrary": {"description": "Badge shown on tracks that exist in local library"},
|
||||
"libraryRemovedMissingFiles": "Removed {count} missing files from library",
|
||||
"@libraryRemovedMissingFiles": {
|
||||
"description": "Snackbar after cleanup",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryCleared": "Library cleared",
|
||||
"@libraryCleared": {"description": "Snackbar after clearing library"},
|
||||
"libraryStorageAccessRequired": "Storage Access Required",
|
||||
"@libraryStorageAccessRequired": {"description": "Dialog title for storage permission"},
|
||||
"libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.",
|
||||
"@libraryStorageAccessMessage": {"description": "Dialog message for storage permission"},
|
||||
"libraryFolderNotExist": "Selected folder does not exist",
|
||||
"@libraryFolderNotExist": {"description": "Error when folder doesn't exist"},
|
||||
"librarySourceDownloaded": "Downloaded",
|
||||
"@librarySourceDownloaded": {"description": "Badge for tracks downloaded via SpotiFLAC"},
|
||||
"librarySourceLocal": "Local",
|
||||
"@librarySourceLocal": {"description": "Badge for tracks from local library scan"},
|
||||
"libraryFilterAll": "All",
|
||||
"@libraryFilterAll": {"description": "Filter chip - show all library items"},
|
||||
"libraryFilterDownloaded": "Downloaded",
|
||||
"@libraryFilterDownloaded": {"description": "Filter chip - show only downloaded items"},
|
||||
"libraryFilterLocal": "Local",
|
||||
"@libraryFilterLocal": {"description": "Filter chip - show only local library items"},
|
||||
|
||||
"libraryFilterTitle": "Filters",
|
||||
"@libraryFilterTitle": {"description": "Filter bottom sheet title"},
|
||||
"libraryFilterReset": "Reset",
|
||||
"@libraryFilterReset": {"description": "Reset all filters button"},
|
||||
"libraryFilterApply": "Apply",
|
||||
"@libraryFilterApply": {"description": "Apply filters button"},
|
||||
"libraryFilterSource": "Source",
|
||||
"@libraryFilterSource": {"description": "Filter section - source type"},
|
||||
"libraryFilterQuality": "Quality",
|
||||
"@libraryFilterQuality": {"description": "Filter section - audio quality"},
|
||||
"libraryFilterQualityHiRes": "Hi-Res (24bit)",
|
||||
"@libraryFilterQualityHiRes": {"description": "Filter option - high resolution audio"},
|
||||
"libraryFilterQualityCD": "CD (16bit)",
|
||||
"@libraryFilterQualityCD": {"description": "Filter option - CD quality audio"},
|
||||
"libraryFilterQualityLossy": "Lossy",
|
||||
"@libraryFilterQualityLossy": {"description": "Filter option - lossy compressed audio"},
|
||||
"libraryFilterFormat": "Format",
|
||||
"@libraryFilterFormat": {"description": "Filter section - file format"},
|
||||
"libraryFilterDate": "Date Added",
|
||||
"@libraryFilterDate": {"description": "Filter section - date range"},
|
||||
"libraryFilterDateToday": "Today",
|
||||
"@libraryFilterDateToday": {"description": "Filter option - today only"},
|
||||
"libraryFilterDateWeek": "This Week",
|
||||
"@libraryFilterDateWeek": {"description": "Filter option - this week"},
|
||||
"libraryFilterDateMonth": "This Month",
|
||||
"@libraryFilterDateMonth": {"description": "Filter option - this month"},
|
||||
"libraryFilterDateYear": "This Year",
|
||||
"@libraryFilterDateYear": {"description": "Filter option - this year"},
|
||||
"libraryFilterActive": "{count} filter(s) active",
|
||||
"@libraryFilterActive": {
|
||||
"description": "Badge showing number of active filters",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"timeJustNow": "Just now",
|
||||
"@timeJustNow": {"description": "Relative time - less than a minute ago"},
|
||||
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
|
||||
"@timeMinutesAgo": {
|
||||
"description": "Relative time - minutes ago",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
|
||||
"@timeHoursAgo": {
|
||||
"description": "Relative time - hours ago",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -85,7 +85,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} =1 {1 альбом} other {{count} альбомов}}",
|
||||
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -624,7 +624,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -661,7 +661,7 @@
|
||||
"@artistCompilations": {
|
||||
"description": "Section header for compilations"
|
||||
},
|
||||
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} =1 {1 релиз} other {{count} релизов}}",
|
||||
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}",
|
||||
"@artistReleases": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -1136,7 +1136,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1206,7 +1206,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1413,7 +1413,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"placeholders": {
|
||||
@@ -1989,7 +1989,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
|
||||
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
@@ -2645,7 +2645,7 @@
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
},
|
||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||
"@downloadedAlbumDeleteMessage": {
|
||||
"description": "Delete confirmation with count",
|
||||
"placeholders": {
|
||||
@@ -2684,7 +2684,7 @@
|
||||
"@downloadedAlbumTapToSelect": {
|
||||
"description": "Selection hint"
|
||||
},
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
||||
"@downloadedAlbumDeleteCount": {
|
||||
"description": "Delete button text with count",
|
||||
"placeholders": {
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {}=1{1 şarkı} other{{count} şarkı}}",
|
||||
"historyTracksCount": "{count, plural, one {1 şarkı} other {{count} şarkı}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {}=1{1 albüm} other{{count} albüm}}",
|
||||
"historyAlbumsCount": "{count, plural, one {1 albüm} other {{count} albüm}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -624,7 +624,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {}=1{1 şarkı} other{{count} şarkı}}",
|
||||
"albumTracks": "{count, plural, one {1 şarkı} other {{count} şarkı}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -1136,7 +1136,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "{count} {count, plural, one {}=1{şarkıyı} other{şarkıyı}} geçmişten silmeye emin misiniz?\n\nBu işlem seçilenleri cihazınızdan da silecektir.",
|
||||
"dialogDeleteSelectedMessage": "{count} {count, plural, one {şarkıyı} other {şarkıyı}} geçmişten silmeye emin misiniz?\n\nBu işlem seçilenleri cihazınızdan da silecektir.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1206,7 +1206,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "{count} {count, plural, one {}=1{şarkı} other{şarkı}} silindi",
|
||||
"snackbarDeletedTracks": "{count} {count, plural, one {şarkı} other {şarkı}} silindi",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
|
||||
@@ -8,6 +8,8 @@ class AppSettings {
|
||||
final String audioQuality;
|
||||
final String filenameFormat;
|
||||
final String downloadDirectory;
|
||||
final String storageMode; // 'app' or 'saf'
|
||||
final String downloadTreeUri; // SAF persistable tree URI
|
||||
final bool autoFallback;
|
||||
final bool embedLyrics;
|
||||
final bool maxQualityCover;
|
||||
@@ -32,15 +34,23 @@ class AppSettings {
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
|
||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||
|
||||
// Local Library Settings
|
||||
final bool localLibraryEnabled; // Enable local library scanning
|
||||
final String localLibraryPath; // Path to scan for audio files
|
||||
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
this.filenameFormat = '{title} - {artist}',
|
||||
this.downloadDirectory = '',
|
||||
this.storageMode = 'app',
|
||||
this.downloadTreeUri = '',
|
||||
this.autoFallback = true,
|
||||
this.embedLyrics = true,
|
||||
this.maxQualityCover = true,
|
||||
@@ -68,6 +78,11 @@ class AppSettings {
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.useAllFilesAccess = false,
|
||||
this.autoExportFailedDownloads = false,
|
||||
this.downloadNetworkMode = 'any',
|
||||
// Local Library defaults
|
||||
this.localLibraryEnabled = false,
|
||||
this.localLibraryPath = '',
|
||||
this.localLibraryShowDuplicates = true,
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -75,6 +90,8 @@ class AppSettings {
|
||||
String? audioQuality,
|
||||
String? filenameFormat,
|
||||
String? downloadDirectory,
|
||||
String? storageMode,
|
||||
String? downloadTreeUri,
|
||||
bool? autoFallback,
|
||||
bool? embedLyrics,
|
||||
bool? maxQualityCover,
|
||||
@@ -103,12 +120,19 @@ class AppSettings {
|
||||
String? tidalHighFormat,
|
||||
bool? useAllFilesAccess,
|
||||
bool? autoExportFailedDownloads,
|
||||
String? downloadNetworkMode,
|
||||
// Local Library
|
||||
bool? localLibraryEnabled,
|
||||
String? localLibraryPath,
|
||||
bool? localLibraryShowDuplicates,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
audioQuality: audioQuality ?? this.audioQuality,
|
||||
filenameFormat: filenameFormat ?? this.filenameFormat,
|
||||
downloadDirectory: downloadDirectory ?? this.downloadDirectory,
|
||||
storageMode: storageMode ?? this.storageMode,
|
||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||
autoFallback: autoFallback ?? this.autoFallback,
|
||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
@@ -136,6 +160,11 @@ class AppSettings {
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
||||
// Local Library
|
||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
||||
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
||||
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
||||
storageMode: json['storageMode'] as String? ?? 'app',
|
||||
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
@@ -41,6 +43,11 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
autoExportFailedDownloads:
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
|
||||
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
||||
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
||||
localLibraryShowDuplicates:
|
||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -49,6 +56,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'audioQuality': instance.audioQuality,
|
||||
'filenameFormat': instance.filenameFormat,
|
||||
'downloadDirectory': instance.downloadDirectory,
|
||||
'storageMode': instance.storageMode,
|
||||
'downloadTreeUri': instance.downloadTreeUri,
|
||||
'autoFallback': instance.autoFallback,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
@@ -76,4 +85,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExploreProvider');
|
||||
|
||||
/// Represents an item in a Spotify home section
|
||||
class ExploreItem {
|
||||
final String id;
|
||||
final String uri;
|
||||
@@ -48,9 +49,22 @@ class ExploreItem {
|
||||
durationMs: json['duration_ms'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'uri': uri,
|
||||
'type': type,
|
||||
'name': name,
|
||||
'artists': artists,
|
||||
'description': description,
|
||||
'cover_url': coverUrl,
|
||||
'provider_id': providerId,
|
||||
'album_id': albumId,
|
||||
'album_name': albumName,
|
||||
'duration_ms': durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
/// Represents a section in Spotify home feed
|
||||
class ExploreSection {
|
||||
final String uri;
|
||||
final String title;
|
||||
@@ -77,9 +91,14 @@ class ExploreSection {
|
||||
isYTMusicQuickPicks: isQuickPicks,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'uri': uri,
|
||||
'title': title,
|
||||
'items': items.map((i) => i.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// State for explore/home feed
|
||||
class ExploreState {
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
@@ -114,7 +133,6 @@ class ExploreState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate greeting based on local device time
|
||||
String _getLocalGreeting() {
|
||||
final hour = DateTime.now().hour;
|
||||
if (hour >= 5 && hour < 12) {
|
||||
@@ -139,23 +157,72 @@ bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Provider for explore/home feed state
|
||||
class ExploreNotifier extends Notifier<ExploreState> {
|
||||
static const _cacheKey = 'explore_home_feed_cache';
|
||||
static const _cacheTsKey = 'explore_home_feed_ts';
|
||||
|
||||
@override
|
||||
ExploreState build() {
|
||||
_restoreFromCache();
|
||||
return const ExploreState();
|
||||
}
|
||||
|
||||
/// Restore cached home feed from SharedPreferences immediately on startup
|
||||
Future<void> _restoreFromCache() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cached = prefs.getString(_cacheKey);
|
||||
final cachedTs = prefs.getInt(_cacheTsKey);
|
||||
if (cached == null || cached.isEmpty) return;
|
||||
|
||||
final data = jsonDecode(cached) as Map<String, dynamic>;
|
||||
final sectionsData = data['sections'] as List<dynamic>? ?? [];
|
||||
final sections = sectionsData
|
||||
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
if (sections.isEmpty) return;
|
||||
|
||||
final lastFetched = cachedTs != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(cachedTs)
|
||||
: null;
|
||||
|
||||
_log.i('Restored ${sections.length} cached explore sections');
|
||||
state = ExploreState(
|
||||
greeting: _getLocalGreeting(),
|
||||
sections: sections,
|
||||
lastFetched: lastFetched,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to restore explore cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Save home feed to SharedPreferences for instant restore on next launch
|
||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final data = {
|
||||
'sections': sections.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
await prefs.setString(_cacheKey, jsonEncode(data));
|
||||
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
|
||||
_log.d('Saved ${sections.length} explore sections to cache');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save explore cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||
|
||||
// Don't refetch if we have data and it's less than 5 minutes old
|
||||
// If we have cached content and it's fresh enough, skip network fetch
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
state.lastFetched != null &&
|
||||
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||
_log.d('Using cached home feed');
|
||||
_log.d('Using cached home feed (fresh enough)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -164,14 +231,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
// Only show loading spinner if we have no cached content to display
|
||||
final showLoading = !state.hasContent;
|
||||
state = state.copyWith(isLoading: showLoading, error: null);
|
||||
|
||||
try {
|
||||
// Find any extension with homeFeed capability
|
||||
final extState = ref.read(extensionProvider);
|
||||
_log.d('Extensions count: ${extState.extensions.length}');
|
||||
|
||||
// Look for extensions with homeFeed capability (prefer spotify-web)
|
||||
Extension? targetExt;
|
||||
for (final extension in extState.extensions) {
|
||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||
@@ -225,14 +292,11 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
|
||||
_log.i('Fetched ${sections.length} sections');
|
||||
|
||||
// Debug: log first section items
|
||||
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||
final firstItem = sections.first.items.first;
|
||||
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
||||
}
|
||||
|
||||
// Always use local device time for greeting to avoid timezone issues
|
||||
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
|
||||
final localGreeting = _getLocalGreeting();
|
||||
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
|
||||
|
||||
@@ -242,6 +306,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
sections: sections,
|
||||
lastFetched: DateTime.now(),
|
||||
);
|
||||
|
||||
// Save to disk cache for instant restore on next app launch
|
||||
_saveToCache(sections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
state = state.copyWith(
|
||||
@@ -251,15 +318,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cached data
|
||||
void clear() {
|
||||
state = const ExploreState();
|
||||
}
|
||||
|
||||
/// Refresh home feed
|
||||
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||
}
|
||||
|
||||
|
||||
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||
return ExploreNotifier();
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExtensionProvider');
|
||||
|
||||
const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||
const _providerPriorityKey = 'provider_priority';
|
||||
|
||||
class Extension {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -452,7 +457,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return const ExtensionState();
|
||||
}
|
||||
|
||||
/// Initialize the extension system
|
||||
Future<void> initialize(String extensionsDir, String dataDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
@@ -485,7 +489,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the list of installed extensions
|
||||
Future<void> refreshExtensions() async {
|
||||
try {
|
||||
final list = await PlatformBridge.getInstalledExtensions();
|
||||
@@ -493,7 +496,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
state = state.copyWith(extensions: extensions);
|
||||
_log.d('Loaded ${extensions.length} extensions');
|
||||
|
||||
// Log search behavior for extensions that have it
|
||||
for (final ext in extensions) {
|
||||
if (ext.searchBehavior != null) {
|
||||
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
|
||||
@@ -505,6 +507,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
@@ -550,7 +553,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall/remove an extension
|
||||
Future<bool> removeExtension(String extensionId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -567,6 +569,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||
try {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
@@ -603,7 +606,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get settings for an extension
|
||||
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||
try {
|
||||
return await PlatformBridge.getExtensionSettings(extensionId);
|
||||
@@ -623,31 +625,67 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load provider priority order
|
||||
Future<void> loadProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getProviderPriority();
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_providerPriorityKey);
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
priority = saved.map((e) => e as String).toList();
|
||||
_log.d('Loaded provider priority from prefs: $priority');
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
} else {
|
||||
// Fallback to Go backend default
|
||||
priority = await PlatformBridge.getProviderPriority();
|
||||
_log.d('Using default provider priority: $priority');
|
||||
}
|
||||
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load provider priority: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> setProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setProviderPriority(priority);
|
||||
state = state.copyWith(providerPriority: priority);
|
||||
_log.d('Updated provider priority: $priority');
|
||||
_log.d('Saved provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load metadata provider priority order
|
||||
Future<void> loadMetadataProviderPriority() async {
|
||||
try {
|
||||
final priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
// Load from SharedPreferences first (persisted)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedJson = prefs.getString(_metadataProviderPriorityKey);
|
||||
|
||||
List<String> priority;
|
||||
if (savedJson != null) {
|
||||
final saved = jsonDecode(savedJson) as List<dynamic>;
|
||||
priority = saved.map((e) => e as String).toList();
|
||||
_log.d('Loaded metadata provider priority from prefs: $priority');
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
} else {
|
||||
// Fallback to Go backend default
|
||||
priority = await PlatformBridge.getMetadataProviderPriority();
|
||||
_log.d('Using default metadata provider priority: $priority');
|
||||
}
|
||||
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load metadata provider priority: $e');
|
||||
@@ -656,16 +694,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
||||
try {
|
||||
// Save to SharedPreferences for persistence
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
|
||||
|
||||
// Sync to Go backend
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
_log.d('Updated metadata provider priority: $priority');
|
||||
_log.d('Saved metadata provider priority: $priority');
|
||||
} catch (e) {
|
||||
_log.e('Failed to set metadata provider priority: $e');
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup all extensions (call on app close)
|
||||
Future<void> cleanup() async {
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
@@ -683,7 +725,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all enabled extensions
|
||||
List<Extension> get enabledExtensions {
|
||||
return state.extensions.where((ext) => ext.enabled).toList();
|
||||
}
|
||||
@@ -698,7 +739,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return providers;
|
||||
}
|
||||
|
||||
/// Get all metadata providers (built-in + extensions)
|
||||
List<String> getAllMetadataProviders() {
|
||||
final providers = ['deezer', 'spotify'];
|
||||
for (final ext in state.extensions) {
|
||||
@@ -708,6 +748,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
List<Extension> get searchProviders {
|
||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('LocalLibrary');
|
||||
|
||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||
|
||||
class LocalLibraryState {
|
||||
final List<LocalLibraryItem> items;
|
||||
final bool isScanning;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
final int scanErrorCount;
|
||||
final bool scanWasCancelled;
|
||||
final DateTime? lastScannedAt;
|
||||
final Set<String> _isrcSet;
|
||||
final Set<String> _trackKeySet;
|
||||
final Map<String, LocalLibraryItem> _byIsrc;
|
||||
|
||||
LocalLibraryState({
|
||||
this.items = const [],
|
||||
this.isScanning = false,
|
||||
this.scanProgress = 0,
|
||||
this.scanCurrentFile,
|
||||
this.scanTotalFiles = 0,
|
||||
this.scanErrorCount = 0,
|
||||
this.scanWasCancelled = false,
|
||||
this.lastScannedAt,
|
||||
}) : _isrcSet = items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => item.isrc!)
|
||||
.toSet(),
|
||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc = Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
||||
|
||||
bool hasTrack(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
return _trackKeySet.contains(key);
|
||||
}
|
||||
|
||||
LocalLibraryItem? getByIsrc(String isrc) => _byIsrc[isrc];
|
||||
|
||||
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
return items.where((item) => item.matchKey == key).firstOrNull;
|
||||
}
|
||||
|
||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||
if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) {
|
||||
return true;
|
||||
}
|
||||
if (trackName != null && artistName != null) {
|
||||
return hasTrack(trackName, artistName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
LocalLibraryState copyWith({
|
||||
List<LocalLibraryItem>? items,
|
||||
bool? isScanning,
|
||||
double? scanProgress,
|
||||
String? scanCurrentFile,
|
||||
int? scanTotalFiles,
|
||||
int? scanErrorCount,
|
||||
bool? scanWasCancelled,
|
||||
DateTime? lastScannedAt,
|
||||
}) {
|
||||
return LocalLibraryState(
|
||||
items: items ?? this.items,
|
||||
isScanning: isScanning ?? this.isScanning,
|
||||
scanProgress: scanProgress ?? this.scanProgress,
|
||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
|
||||
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
|
||||
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
|
||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||
Timer? _progressTimer;
|
||||
bool _isLoaded = false;
|
||||
bool _scanCancelRequested = false;
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
});
|
||||
|
||||
Future.microtask(() async {
|
||||
await _loadFromDatabase();
|
||||
});
|
||||
return LocalLibraryState();
|
||||
}
|
||||
|
||||
Future<void> _loadFromDatabase() async {
|
||||
if (_isLoaded) return;
|
||||
_isLoaded = true;
|
||||
|
||||
try {
|
||||
final jsonList = await _db.getAll();
|
||||
final items = jsonList
|
||||
.map((e) => LocalLibraryItem.fromJson(e))
|
||||
.toList();
|
||||
|
||||
DateTime? lastScannedAt;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to load lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(items: items, lastScannedAt: lastScannedAt);
|
||||
_log.i('Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt');
|
||||
} catch (e, stack) {
|
||||
_log.e('Failed to load library from database: $e', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadFromStorage() async {
|
||||
_isLoaded = false;
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
|
||||
Future<void> startScan(String folderPath) async {
|
||||
if (state.isScanning) {
|
||||
_log.w('Scan already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
_scanCancelRequested = false;
|
||||
_log.i('Starting library scan: $folderPath');
|
||||
state = state.copyWith(
|
||||
isScanning: true,
|
||||
scanProgress: 0,
|
||||
scanCurrentFile: null,
|
||||
scanTotalFiles: 0,
|
||||
scanErrorCount: 0,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
|
||||
try {
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
final coverCacheDir = '${cacheDir.path}/library_covers';
|
||||
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
|
||||
_log.i('Cover cache directory set to: $coverCacheDir');
|
||||
} catch (e) {
|
||||
_log.w('Failed to set cover cache directory: $e');
|
||||
}
|
||||
|
||||
_startProgressPolling();
|
||||
|
||||
try {
|
||||
final isSaf = folderPath.startsWith('content://');
|
||||
final results = isSaf
|
||||
? await PlatformBridge.scanSafTree(folderPath)
|
||||
: await PlatformBridge.scanLibraryFolder(folderPath);
|
||||
if (_scanCancelRequested) {
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final items = <LocalLibraryItem>[];
|
||||
for (final json in results) {
|
||||
final item = LocalLibraryItem.fromJson(json);
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
);
|
||||
|
||||
_log.i('Scan complete: ${items.length} tracks found');
|
||||
} catch (e, stack) {
|
||||
_log.e('Library scan failed: $e', e, stack);
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
||||
} finally {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
}
|
||||
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
|
||||
state = state.copyWith(
|
||||
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
|
||||
scanCurrentFile: progress['current_file'] as String?,
|
||||
scanTotalFiles: progress['total_files'] as int? ?? 0,
|
||||
scanErrorCount: progress['error_count'] as int? ?? 0,
|
||||
);
|
||||
|
||||
if (progress['is_complete'] == true) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
}
|
||||
|
||||
Future<void> cancelScan() async {
|
||||
if (!state.isScanning) return;
|
||||
|
||||
_log.i('Cancelling library scan');
|
||||
_scanCancelRequested = true;
|
||||
await PlatformBridge.cancelLibraryScan();
|
||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final removed = await _db.cleanupMissingFiles();
|
||||
if (removed > 0) {
|
||||
await reloadFromStorage();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
Future<void> clearLibrary() async {
|
||||
await _db.clearAll();
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_lastScannedAtKey);
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = LocalLibraryState();
|
||||
_log.i('Library cleared');
|
||||
}
|
||||
|
||||
Future<void> removeItem(String id) async {
|
||||
await _db.delete(id);
|
||||
state = state.copyWith(
|
||||
items: state.items.where((item) => item.id != id).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||
return state.existsInLibrary(
|
||||
isrc: isrc,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
);
|
||||
}
|
||||
|
||||
LocalLibraryItem? getByIsrc(String isrc) {
|
||||
return state.getByIsrc(isrc);
|
||||
}
|
||||
|
||||
LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) {
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = state.getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
if (trackName != null && artistName != null) {
|
||||
return state.findByTrackAndArtist(trackName, artistName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<LocalLibraryItem>> search(String query) async {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
final results = await _db.search(query);
|
||||
return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
Future<int> getCount() async {
|
||||
return await _db.getCount();
|
||||
}
|
||||
}
|
||||
|
||||
final localLibraryProvider =
|
||||
NotifierProvider<LocalLibraryNotifier, LocalLibraryState>(
|
||||
LocalLibraryNotifier.new,
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -8,9 +9,11 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 1;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
AppSettings build() {
|
||||
@@ -25,11 +28,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
await _runMigrations(prefs);
|
||||
|
||||
_applySpotifyCredentials();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
|
||||
await _loadSpotifyClientSecret(prefs);
|
||||
|
||||
_applySpotifyCredentials();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
@@ -43,13 +48,49 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
if (lastMigration < _currentMigrationVersion) {
|
||||
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
|
||||
state = state.copyWith(storageMode: 'saf');
|
||||
}
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||
final settingsToSave = state.copyWith(
|
||||
spotifyClientSecret: '',
|
||||
);
|
||||
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson()));
|
||||
}
|
||||
|
||||
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
||||
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey);
|
||||
final prefsSecret = state.spotifyClientSecret;
|
||||
|
||||
if ((storedSecret == null || storedSecret.isEmpty) &&
|
||||
prefsSecret.isNotEmpty) {
|
||||
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret);
|
||||
}
|
||||
|
||||
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
|
||||
? storedSecret
|
||||
: (prefsSecret.isNotEmpty ? prefsSecret : '');
|
||||
|
||||
if (effectiveSecret != state.spotifyClientSecret) {
|
||||
state = state.copyWith(spotifyClientSecret: effectiveSecret);
|
||||
}
|
||||
|
||||
if (prefsSecret.isNotEmpty) {
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _storeSpotifyClientSecret(String secret) async {
|
||||
if (secret.isEmpty) {
|
||||
await _secureStorage.delete(key: _spotifyClientSecretKey);
|
||||
} else {
|
||||
await _secureStorage.write(key: _spotifyClientSecretKey, value: secret);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
@@ -82,6 +123,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setStorageMode(String mode) {
|
||||
final normalized = mode == 'saf' ? 'saf' : 'app';
|
||||
state = state.copyWith(storageMode: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDownloadTreeUri(String uri, {String? displayName}) {
|
||||
final nextDisplay = displayName ?? state.downloadDirectory;
|
||||
state = state.copyWith(
|
||||
downloadTreeUri: uri,
|
||||
storageMode: uri.isNotEmpty ? 'saf' : state.storageMode,
|
||||
downloadDirectory: nextDisplay,
|
||||
);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoFallback(bool enabled) {
|
||||
state = state.copyWith(autoFallback: enabled);
|
||||
_saveSettings();
|
||||
@@ -157,25 +214,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSpotifyClientSecret(String clientSecret) {
|
||||
Future<void> setSpotifyClientSecret(String clientSecret) async {
|
||||
state = state.copyWith(spotifyClientSecret: clientSecret);
|
||||
await _storeSpotifyClientSecret(clientSecret);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSpotifyCredentials(String clientId, String clientSecret) {
|
||||
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: clientId,
|
||||
spotifyClientSecret: clientSecret,
|
||||
);
|
||||
await _storeSpotifyClientSecret(clientSecret);
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
|
||||
void clearSpotifyCredentials() {
|
||||
Future<void> clearSpotifyCredentials() async {
|
||||
state = state.copyWith(
|
||||
spotifyClientId: '',
|
||||
spotifyClientSecret: '',
|
||||
);
|
||||
await _storeSpotifyClientSecret('');
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
@@ -245,6 +305,26 @@ void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(autoExportFailedDownloads: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDownloadNetworkMode(String mode) {
|
||||
state = state.copyWith(downloadNetworkMode: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryEnabled(bool enabled) {
|
||||
state = state.copyWith(localLibraryEnabled: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryPath(String path) {
|
||||
state = state.copyWith(localLibraryPath: path);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryShowDuplicates(bool show) {
|
||||
state = state.copyWith(localLibraryShowDuplicates: show);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -7,8 +7,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
final _log = AppLogger('StoreProvider');
|
||||
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
||||
|
||||
/// Compare two semantic version strings
|
||||
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
int compareVersions(String v1, String v2) {
|
||||
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
@@ -25,8 +23,8 @@ int compareVersions(String v1, String v2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Extension categories
|
||||
class StoreCategory {
|
||||
|
||||
static const String metadata = 'metadata';
|
||||
static const String download = 'download';
|
||||
static const String utility = 'utility';
|
||||
@@ -111,13 +109,13 @@ class StoreExtension {
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this extension requires a higher app version than current
|
||||
bool get requiresNewerApp {
|
||||
if (minAppVersion == null || minAppVersion!.isEmpty) return false;
|
||||
return compareVersions(minAppVersion!, AppInfo.version) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StoreState {
|
||||
final List<StoreExtension> extensions;
|
||||
final String? selectedCategory;
|
||||
@@ -164,7 +162,6 @@ class StoreState {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get filtered extensions based on category and search
|
||||
List<StoreExtension> get filteredExtensions {
|
||||
var result = extensions;
|
||||
|
||||
@@ -186,13 +183,11 @@ class StoreState {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Count of extensions with updates available
|
||||
int get updatesAvailableCount {
|
||||
return extensions.where((e) => e.hasUpdate).length;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing extension store
|
||||
class StoreNotifier extends Notifier<StoreState> {
|
||||
@override
|
||||
StoreState build() {
|
||||
@@ -215,7 +210,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh extensions from store
|
||||
Future<void> refresh({bool forceRefresh = false}) async {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
@@ -240,7 +234,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set search query
|
||||
void setSearchQuery(String query) {
|
||||
state = state.copyWith(searchQuery: query);
|
||||
}
|
||||
@@ -249,7 +242,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||
}
|
||||
|
||||
/// Download and install extension
|
||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
@@ -275,6 +267,7 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
|
||||
@@ -3,23 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/models/theme_settings.dart';
|
||||
|
||||
/// Provider for theme settings state management
|
||||
final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
|
||||
return ThemeNotifier();
|
||||
});
|
||||
|
||||
/// Notifier for managing theme settings with persistence
|
||||
class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
ThemeSettings build() {
|
||||
// Load settings asynchronously on first access
|
||||
_loadFromStorage();
|
||||
return const ThemeSettings();
|
||||
}
|
||||
|
||||
/// Load theme settings from SharedPreferences
|
||||
Future<void> _loadFromStorage() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
@@ -39,7 +35,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save current settings to SharedPreferences
|
||||
Future<void> _saveToStorage() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
@@ -52,13 +47,11 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set theme mode (light, dark, or system)
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
state = state.copyWith(themeMode: mode);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Enable or disable dynamic color from wallpaper
|
||||
Future<void> setUseDynamicColor(bool value) async {
|
||||
state = state.copyWith(useDynamicColor: value);
|
||||
await _saveToStorage();
|
||||
@@ -70,19 +63,16 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Set seed color from int value
|
||||
Future<void> setSeedColorValue(int colorValue) async {
|
||||
state = state.copyWith(seedColorValue: colorValue);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Enable or disable AMOLED mode (pure black background)
|
||||
Future<void> setUseAmoled(bool value) async {
|
||||
state = state.copyWith(useAmoled: value);
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Helper to convert string to ThemeMode
|
||||
ThemeMode _themeModeFromString(String? value) {
|
||||
if (value == null) return ThemeMode.system;
|
||||
return ThemeMode.values.firstWhere(
|
||||
@@ -91,3 +81,4 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,11 +193,34 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
// Step 1: Check for extension URL handlers first (handles YT Music, etc.)
|
||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
// Check if we got valid data
|
||||
if (result != null && result['type'] == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final name = trackData['name']?.toString() ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
} else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||
break;
|
||||
} else if (result != null && result['type'] == 'artist') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt < 3) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
@@ -206,6 +229,15 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
|
||||
if (track.name.isEmpty) {
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'Failed to load track metadata from extension',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
@@ -251,8 +283,131 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Try Deezer URL parsing
|
||||
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
||||
_log.i('Detected Deezer URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
} else if (type == 'album') {
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: id,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
playlistName: playlistInfo['name'] as String?,
|
||||
coverUrl: playlistInfo['images'] as String?,
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Try Tidal URL parsing
|
||||
if (url.contains('tidal.com')) {
|
||||
_log.i('Detected Tidal URL, parsing...');
|
||||
final parsed = await PlatformBridge.parseTidalUrl(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
_log.i('Tidal URL parsed: type=$type, id=$id');
|
||||
|
||||
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
|
||||
if (type == 'track') {
|
||||
try {
|
||||
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final spotifyUrl = conversion['spotify_url'] as String?;
|
||||
final deezerUrl = conversion['deezer_url'] as String?;
|
||||
|
||||
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
||||
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
return;
|
||||
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
||||
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl);
|
||||
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
final track = _parseTrack(trackData);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// For album/artist/playlist, not yet supported
|
||||
state = TrackState(
|
||||
isLoading: false,
|
||||
error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Fall back to Spotify parsing
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
|
||||
@@ -264,7 +419,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (type == 'track') {
|
||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||
@@ -556,7 +711,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final tracks = List<Track>.from(state.tracks);
|
||||
tracks[index] = updatedTrack;
|
||||
state = state.copyWith(tracks: tracks);
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
// Silently ignore update failures - track may have been removed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -10,7 +9,9 @@ import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
|
||||
@@ -44,10 +45,10 @@ class AlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final List<Track>? tracks; // Optional - will fetch if null
|
||||
final String? extensionId; // If from extension
|
||||
final String? artistId; // Artist ID for navigation
|
||||
final String? artistName; // Artist name for navigation
|
||||
final List<Track>? tracks;
|
||||
final String? extensionId;
|
||||
final String? artistId;
|
||||
final String? artistName;
|
||||
|
||||
const AlbumScreen({
|
||||
super.key,
|
||||
@@ -92,13 +93,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
});
|
||||
|
||||
// Use provided tracks if not empty, otherwise try cache
|
||||
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
||||
_tracks = widget.tracks;
|
||||
} else {
|
||||
_tracks = _AlbumCache.get(widget.albumId);
|
||||
}
|
||||
_artistId = widget.artistId; // Use provided artist ID if available
|
||||
_artistId = widget.artistId;
|
||||
|
||||
if (_tracks == null || _tracks!.isEmpty) {
|
||||
_fetchTracks();
|
||||
@@ -121,7 +121,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
Future<void> _extractDominantColor() async {
|
||||
if (widget.coverUrl == null) return;
|
||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
||||
if (mounted && color != null) {
|
||||
@@ -130,21 +130,18 @@ Future<void> _extractDominantColor() async {
|
||||
}
|
||||
|
||||
String _formatReleaseDate(String date) {
|
||||
// Handle formats: "2024-01-15", "2024-01", "2024"
|
||||
if (date.length >= 10) {
|
||||
// Full date: 2024-01-15
|
||||
final parts = date.substring(0, 10).split('-');
|
||||
if (parts.length == 3) {
|
||||
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
|
||||
return '${parts[2]}/${parts[1]}/${parts[0]}';
|
||||
}
|
||||
} else if (date.length >= 7) {
|
||||
// Month: 2024-01
|
||||
final parts = date.split('-');
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[1]}/${parts[0]}'; // MM/YYYY
|
||||
return '${parts[1]}/${parts[0]}';
|
||||
}
|
||||
}
|
||||
return date; // Year only or unknown format
|
||||
return date;
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
@@ -163,7 +160,6 @@ Future<void> _fetchTracks() async {
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
// Extract artist ID from album_info if available
|
||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||
final artistId = albumInfo?['artist_id'] as String?;
|
||||
|
||||
@@ -235,7 +231,7 @@ Future<void> _fetchTracks() async {
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||
final coverSize = screenWidth * 0.5;
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return SliverAppBar(
|
||||
@@ -268,7 +264,6 @@ Future<void> _fetchTracks() async {
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
@@ -500,11 +495,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
|
||||
}
|
||||
|
||||
void _navigateToArtist(BuildContext context, String artistName) {
|
||||
// Use stored artist ID if available, otherwise use a placeholder
|
||||
final artistId = _artistId ??
|
||||
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
|
||||
|
||||
// Don't navigate if artist ID is unknown
|
||||
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Artist information not available')),
|
||||
@@ -512,7 +505,6 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
|
||||
return;
|
||||
}
|
||||
|
||||
// If from extension, use ExtensionArtistScreen
|
||||
if (widget.extensionId != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -620,6 +612,17 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
|
||||
final isInLocalLibrary = showLocalLibraryIndicator
|
||||
? ref.watch(localLibraryProvider.select((state) =>
|
||||
state.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
)))
|
||||
: false;
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
@@ -649,22 +652,51 @@ child: ListTile(
|
||||
),
|
||||
),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))),
|
||||
if (isInLocalLibrary) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 3),
|
||||
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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, required bool isInLocalLibrary}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInLocalLibrary) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
|
||||
}
|
||||
@@ -684,6 +716,7 @@ child: ListTile(
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 44.0;
|
||||
@@ -691,7 +724,7 @@ child: ListTile(
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||
);
|
||||
} else if (isFinalizing) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -12,7 +11,9 @@ import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
@@ -73,7 +74,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
||||
final int? monthlyListeners;
|
||||
final List<ArtistAlbum>? albums;
|
||||
final List<Track>? topTracks;
|
||||
final String? extensionId; // If set, skip fetching from Spotify/Deezer
|
||||
final String? extensionId;
|
||||
|
||||
const ArtistScreen({
|
||||
super.key,
|
||||
@@ -102,7 +103,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
// Selection mode state
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedAlbumIds = {};
|
||||
bool _isFetchingDiscography = false;
|
||||
@@ -111,7 +111,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Setup scroll listener for sticky title
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -321,11 +320,9 @@ return PopScope(
|
||||
if (compilations.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
|
||||
],
|
||||
// Add padding at bottom for selection bar
|
||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||
],
|
||||
),
|
||||
// Selection action bar
|
||||
if (_isSelectionMode)
|
||||
_buildSelectionBar(context, colorScheme, albums),
|
||||
],
|
||||
@@ -403,14 +400,12 @@ return PopScope(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Close button
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Selection info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -432,13 +427,11 @@ return PopScope(
|
||||
],
|
||||
),
|
||||
),
|
||||
// Select all / Deselect button
|
||||
TextButton(
|
||||
onPressed: allSelected ? _deselectAll : () => _selectAll(allAlbums),
|
||||
child: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Download button
|
||||
FilledButton.icon(
|
||||
onPressed: selectedCount > 0 ? () => _downloadSelectedAlbums(context, selectedAlbums) : null,
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
@@ -472,7 +465,6 @@ return PopScope(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
@@ -482,7 +474,6 @@ return PopScope(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// Title
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Row(
|
||||
@@ -576,7 +567,6 @@ return PopScope(
|
||||
|
||||
setState(() => _isFetchingDiscography = true);
|
||||
|
||||
// Show progress dialog
|
||||
if (!mounted) {
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
return;
|
||||
@@ -598,7 +588,6 @@ return PopScope(
|
||||
int fetchedCount = 0;
|
||||
int failedCount = 0;
|
||||
|
||||
// Fetch tracks from each album
|
||||
for (final album in albums) {
|
||||
if (!_isFetchingDiscography) break; // Cancelled
|
||||
|
||||
@@ -619,12 +608,10 @@ return PopScope(
|
||||
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
|
||||
// Close progress dialog
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
// Show warning if some albums failed
|
||||
if (failedCount > 0 && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.discographyFailedToFetch)),
|
||||
@@ -667,14 +654,12 @@ return PopScope(
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: qualityOverride,
|
||||
);
|
||||
|
||||
// Show success message
|
||||
if (mounted) {
|
||||
final message = skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount)
|
||||
@@ -697,14 +682,12 @@ return PopScope(
|
||||
|
||||
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
|
||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||
// Extension album
|
||||
final result = await PlatformBridge.getAlbumWithExtension(album.providerId!, album.id);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
} else if (album.id.startsWith('deezer:')) {
|
||||
// Deezer album
|
||||
final deezerId = album.id.replaceFirst('deezer:', '');
|
||||
final metadata = await PlatformBridge.getDeezerMetadata('album', deezerId);
|
||||
if (metadata['tracks'] != null) {
|
||||
@@ -712,7 +695,6 @@ return PopScope(
|
||||
return tracksList.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album)).toList();
|
||||
}
|
||||
} else {
|
||||
// Spotify album
|
||||
final url = 'https://open.spotify.com/album/${album.id}';
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
@@ -955,6 +937,18 @@ if (hasValidImage)
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
// Check local library for duplicate detection
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
|
||||
final isInLocalLibrary = showLocalLibraryIndicator
|
||||
? ref.watch(localLibraryProvider.select((state) =>
|
||||
state.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
)))
|
||||
: false;
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
@@ -964,7 +958,7 @@ if (hasValidImage)
|
||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
|
||||
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
@@ -1042,6 +1036,7 @@ if (hasValidImage)
|
||||
isFinalizing: isFinalizing,
|
||||
showAsDownloaded: showAsDownloaded,
|
||||
isInHistory: isInHistory,
|
||||
isInLocalLibrary: isInLocalLibrary,
|
||||
progress: progress,
|
||||
),
|
||||
],
|
||||
@@ -1051,14 +1046,23 @@ if (hasValidImage)
|
||||
}
|
||||
|
||||
/// Handle tap on popular track item
|
||||
void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory}) async {
|
||||
void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInLocalLibrary) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
|
||||
@@ -1082,6 +1086,7 @@ if (hasValidImage)
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 40.0;
|
||||
@@ -1089,7 +1094,7 @@ if (hasValidImage)
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
|
||||
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
|
||||
@@ -180,10 +178,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
if (item != null) {
|
||||
try {
|
||||
final file = File(item.filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
await deleteFile(item.filePath);
|
||||
} catch (_) {}
|
||||
historyNotifier.removeFromHistory(id);
|
||||
deletedCount++;
|
||||
@@ -202,8 +197,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
final mimeType = audioMimeTypeForPath(filePath);
|
||||
await OpenFilex.open(filePath, type: mimeType);
|
||||
await openFile(filePath);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
+73
-24
@@ -13,11 +13,13 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/explore_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
@@ -49,19 +51,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
late final ProviderSubscription<TrackState> _trackStateSub;
|
||||
late final ProviderSubscription<bool> _extensionInitSub;
|
||||
|
||||
/// 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);
|
||||
|
||||
List<DownloadHistoryItem>? _recentAccessHistoryCache;
|
||||
@@ -502,7 +495,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
: null;
|
||||
|
||||
final hasExploreContent = exploreSections.isNotEmpty;
|
||||
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && hasExploreContent;
|
||||
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && (hasHomeFeedExtension || hasExploreContent) && hasExploreContent;
|
||||
|
||||
// Get current search extension and its filters
|
||||
final settings = ref.watch(settingsProvider);
|
||||
@@ -512,7 +505,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
Extension? currentSearchExtension;
|
||||
List<SearchFilter> searchFilters = [];
|
||||
|
||||
// Check if using extension search provider
|
||||
final isUsingExtensionSearch = currentSearchProvider != null &&
|
||||
currentSearchProvider.isNotEmpty &&
|
||||
extState.extensions.any((e) => e.id == currentSearchProvider && e.enabled);
|
||||
@@ -640,8 +632,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
),
|
||||
),
|
||||
|
||||
// Search filter bar (only shown when has search results or loading search)
|
||||
if (searchFilters.isNotEmpty && (hasActualResults || isLoading))
|
||||
// Search filter bar (only shown when has search results)
|
||||
if (searchFilters.isNotEmpty && hasActualResults)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSearchFilterBar(
|
||||
searchFilters,
|
||||
@@ -2417,6 +2409,18 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
// Check local library for duplicate detection
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
|
||||
final isInLocalLibrary = showLocalLibraryIndicator
|
||||
? ref.watch(localLibraryProvider.select((state) =>
|
||||
state.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
)))
|
||||
: false;
|
||||
|
||||
double thumbWidth = 56;
|
||||
double thumbHeight = 56;
|
||||
|
||||
@@ -2452,7 +2456,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
@@ -2490,16 +2494,51 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
track.artistName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
track.artistName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (isInLocalLibrary) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
context.l10n.libraryInLibrary,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_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, isInLocalLibrary: isInLocalLibrary, progress: progress),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -2516,14 +2555,23 @@ 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, required bool isInLocalLibrary}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInLocalLibrary) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
|
||||
@@ -2545,6 +2593,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 44.0;
|
||||
@@ -2552,7 +2601,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
|
||||
/// Screen to display tracks from a local library album
|
||||
class LocalAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumName;
|
||||
final String artistName;
|
||||
final String? coverPath;
|
||||
final List<LocalLibraryItem> tracks;
|
||||
|
||||
const LocalAlbumScreen({
|
||||
super.key,
|
||||
required this.albumName,
|
||||
required this.artistName,
|
||||
this.coverPath,
|
||||
required this.tracks,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<LocalAlbumScreen> createState() => _LocalAlbumScreenState();
|
||||
}
|
||||
|
||||
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
Color? _dominantColor;
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late List<LocalLibraryItem> _sortedTracksCache;
|
||||
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
||||
late List<int> _sortedDiscNumbersCache;
|
||||
late bool _hasMultipleDiscsCache;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_rebuildTrackCaches();
|
||||
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
|
||||
if (cachedColor != null) {
|
||||
_dominantColor = cachedColor;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_extractDominantColor();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant LocalAlbumScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.tracks, widget.tracks) ||
|
||||
oldWidget.tracks.length != widget.tracks.length) {
|
||||
_rebuildTrackCaches();
|
||||
}
|
||||
if (oldWidget.coverPath != widget.coverPath) {
|
||||
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
|
||||
if (cachedColor != null && cachedColor != _dominantColor) {
|
||||
_dominantColor = cachedColor;
|
||||
}
|
||||
_extractDominantColor();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
if (widget.coverPath == null || widget.coverPath!.isEmpty) return;
|
||||
|
||||
// Extract color from local file
|
||||
final color = await PaletteService.instance.extractDominantColorFromFile(widget.coverPath!);
|
||||
if (mounted && color != null && color != _dominantColor) {
|
||||
setState(() {
|
||||
_dominantColor = color;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _buildSortedTracks() {
|
||||
final tracks = List<LocalLibraryItem>.from(widget.tracks);
|
||||
tracks.sort((a, b) {
|
||||
// Sort by disc number first, then by track number
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
return a.trackName.compareTo(b.trackName);
|
||||
});
|
||||
return tracks;
|
||||
}
|
||||
|
||||
void _rebuildTrackCaches() {
|
||||
_sortedTracksCache = _buildSortedTracks();
|
||||
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
|
||||
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
|
||||
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
|
||||
}
|
||||
|
||||
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(List<LocalLibraryItem> tracks) {
|
||||
final discMap = <int, List<LocalLibraryItem>>{};
|
||||
for (final track in tracks) {
|
||||
final discNumber = track.discNumber ?? 1;
|
||||
discMap.putIfAbsent(discNumber, () => []).add(track);
|
||||
}
|
||||
return discMap;
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String itemId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
_selectedIds.add(itemId);
|
||||
});
|
||||
}
|
||||
|
||||
void _exitSelectionMode() {
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleSelection(String itemId) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(itemId)) {
|
||||
_selectedIds.remove(itemId);
|
||||
if (_selectedIds.isEmpty) {
|
||||
_isSelectionMode = false;
|
||||
}
|
||||
} else {
|
||||
_selectedIds.add(itemId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _selectAll(List<LocalLibraryItem> tracks) {
|
||||
setState(() {
|
||||
_selectedIds.addAll(tracks.map((e) => e.id));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _deleteSelected(List<LocalLibraryItem> currentTracks) async {
|
||||
final count = _selectedIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.downloadedAlbumDeleteSelected),
|
||||
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(context.l10n.dialogDelete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final libraryNotifier = ref.read(localLibraryProvider.notifier);
|
||||
final idsToDelete = _selectedIds.toList();
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final id in idsToDelete) {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
if (item != null) {
|
||||
try {
|
||||
await deleteFile(item.filePath);
|
||||
} catch (_) {}
|
||||
libraryNotifier.removeItem(id);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
|
||||
);
|
||||
|
||||
// Go back if all tracks were deleted
|
||||
if (deletedCount == currentTracks.length) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
await openFile(filePath);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
final tracks = _sortedTracksCache;
|
||||
|
||||
// Show empty state if no tracks found
|
||||
if (tracks.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.albumName),
|
||||
),
|
||||
body: const Center(
|
||||
child: Text('No tracks found for this album'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final validIds = tracks.map((t) => t.id).toSet();
|
||||
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _isSelectionMode = false);
|
||||
});
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: !_isSelectionMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isSelectionMode) {
|
||||
_exitSelectionMode();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme, tracks),
|
||||
_buildTrackListHeader(context, colorScheme, tracks),
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||
],
|
||||
),
|
||||
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5;
|
||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
widget.albumName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background with dominant color
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cover image centered
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: widget.coverPath != null
|
||||
? Image.file(
|
||||
File(widget.coverPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: (coverSize * 2).toInt(),
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
);
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.albumName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.artistName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
// "Local" badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Track count
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Quality badge if all tracks have the same quality
|
||||
if (_getCommonQuality(tracks) != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCommonQuality(tracks)!.contains('24')
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getCommonQuality(tracks)!,
|
||||
style: TextStyle(
|
||||
color: _getCommonQuality(tracks)!.contains('24')
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _getCommonQuality(List<LocalLibraryItem> tracks) {
|
||||
if (tracks.isEmpty) return null;
|
||||
final first = tracks.first;
|
||||
if (first.bitDepth == null || first.sampleRate == null) return null;
|
||||
|
||||
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
|
||||
for (final track in tracks) {
|
||||
if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return firstQuality;
|
||||
}
|
||||
|
||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||
const Spacer(),
|
||||
if (!_isSelectionMode)
|
||||
TextButton.icon(
|
||||
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||
icon: const Icon(Icons.checklist, size: 18),
|
||||
label: Text(context.l10n.actionSelect),
|
||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
|
||||
final discGroups = _discGroupsCache;
|
||||
final hasMultipleDiscs = _hasMultipleDiscsCache;
|
||||
|
||||
final slivers = <Widget>[];
|
||||
|
||||
for (final discNumber in _sortedDiscNumbersCache) {
|
||||
final discTracks = discGroups[discNumber]!;
|
||||
|
||||
if (hasMultipleDiscs) {
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
slivers.add(
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]),
|
||||
childCount: discTracks.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverMainAxisGroup(slivers: slivers);
|
||||
}
|
||||
|
||||
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) {
|
||||
final isSelected = _selectedIds.contains(track.id);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(track.id)
|
||||
: () => _openFile(track.filePath),
|
||||
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
track.trackNumber?.toString() ?? '-',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(
|
||||
track.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (track.format != null) ...[
|
||||
Text(' • ', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)),
|
||||
Text(
|
||||
track.format!.toUpperCase(),
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _isSelectionMode ? null : IconButton(
|
||||
onPressed: () => _openFile(track.filePath),
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks, double bottomPadding) {
|
||||
final selectedCount = _selectedIds.length;
|
||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
if (allSelected) {
|
||||
_exitSelectionMode();
|
||||
} else {
|
||||
_selectAll(tracks);
|
||||
}
|
||||
},
|
||||
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
||||
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
|
||||
: context.l10n.downloadedAlbumSelectToDelete,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+261
-41
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
@@ -61,18 +62,35 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSharedUrl(String url) {
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
Future<void> _handleSharedUrl(String url) async {
|
||||
// Wait for extensions to be initialized before handling URL
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized) {
|
||||
_log.d('Waiting for extensions to initialize before handling URL...');
|
||||
// Wait up to 5 seconds for extensions to initialize
|
||||
for (int i = 0; i < 50; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted) return;
|
||||
if (ref.read(extensionProvider).isInitialized) {
|
||||
_log.d('Extensions initialized, proceeding with URL handling');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
|
||||
if (_currentIndex != 0) {
|
||||
_onNavTap(0);
|
||||
}
|
||||
ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.loadingSharedLink)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +101,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.checkForUpdates) return;
|
||||
|
||||
final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel);
|
||||
final updateInfo = await UpdateChecker.checkForUpdate(
|
||||
channel: settings.updateChannel,
|
||||
);
|
||||
if (updateInfo != null && mounted) {
|
||||
showUpdateDialog(
|
||||
context,
|
||||
@@ -104,6 +124,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
void _onNavTap(int index) {
|
||||
if (_currentIndex != index) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _currentIndex = index);
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
@@ -122,35 +143,38 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
void _handleBackPress() {
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
if (isKeyboardVisible) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
||||
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||
|
||||
if (_currentIndex == 0 &&
|
||||
!trackState.isLoading &&
|
||||
(trackState.hasSearchText || trackState.hasContent)) {
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (_currentIndex != 0) {
|
||||
_onNavTap(0);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (trackState.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
} else {
|
||||
_lastBackPress = now;
|
||||
@@ -166,19 +190,26 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final queueState = ref.watch(
|
||||
downloadQueueProvider.select((s) => s.queuedCount),
|
||||
);
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
||||
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
|
||||
|
||||
final showStore = ref.watch(
|
||||
settingsProvider.select((s) => s.showExtensionStore),
|
||||
);
|
||||
final storeUpdatesCount = ref.watch(
|
||||
storeProvider.select((s) => s.updatesAvailableCount),
|
||||
);
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
|
||||
final canPop = _currentIndex == 0 &&
|
||||
!trackState.hasSearchText &&
|
||||
!trackState.hasContent &&
|
||||
!trackState.isLoading &&
|
||||
!trackState.isShowingRecentAccess &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
final canPop =
|
||||
_currentIndex == 0 &&
|
||||
!trackState.hasSearchText &&
|
||||
!trackState.hasContent &&
|
||||
!trackState.isLoading &&
|
||||
!trackState.isShowingRecentAccess &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
final tabs = <Widget>[
|
||||
const HomeTab(),
|
||||
@@ -195,21 +226,23 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final destinations = <NavigationDestination>[
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
selectedIcon: const Icon(Icons.home),
|
||||
selectedIcon: BouncingIcon(child: const Icon(Icons.home)),
|
||||
label: l10n.navHome,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history_outlined),
|
||||
child: const Icon(Icons.library_music_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history),
|
||||
selectedIcon: SlidingIcon(
|
||||
child: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.library_music),
|
||||
),
|
||||
),
|
||||
label: l10n.navHistory,
|
||||
label: l10n.navLibrary,
|
||||
),
|
||||
if (showStore)
|
||||
NavigationDestination(
|
||||
@@ -218,16 +251,18 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store),
|
||||
selectedIcon: SwingIcon(
|
||||
child: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store),
|
||||
),
|
||||
),
|
||||
label: l10n.navStore,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
selectedIcon: const Icon(Icons.settings),
|
||||
selectedIcon: SpinIcon(child: const Icon(Icons.settings)),
|
||||
label: l10n.navSettings,
|
||||
),
|
||||
];
|
||||
@@ -248,7 +283,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
if (didPop) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_handleBackPress();
|
||||
},
|
||||
child: Scaffold(
|
||||
@@ -261,13 +296,198 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 200),
|
||||
animationDuration: const Duration(milliseconds: 500),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.03),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BouncingIcon extends StatefulWidget {
|
||||
final Widget child;
|
||||
const BouncingIcon({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<BouncingIcon> createState() => _BouncingIconState();
|
||||
}
|
||||
|
||||
class _BouncingIconState extends State<BouncingIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.1,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut));
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaleTransition(scale: _scaleAnimation, child: widget.child);
|
||||
}
|
||||
}
|
||||
|
||||
class SlidingIcon extends StatefulWidget {
|
||||
final Widget child;
|
||||
const SlidingIcon({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<SlidingIcon> createState() => _SlidingIconState();
|
||||
}
|
||||
|
||||
class _SlidingIconState extends State<SlidingIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<Offset> _offsetAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
vsync: this,
|
||||
);
|
||||
_offsetAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.5),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(position: _offsetAnimation, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SwingIcon extends StatefulWidget {
|
||||
final Widget child;
|
||||
const SwingIcon({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<SwingIcon> createState() => _SwingIconState();
|
||||
}
|
||||
|
||||
class _SwingIconState extends State<SwingIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _rotationAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
// Create a swinging motion (like a pendulum/sign)
|
||||
_rotationAnimation = TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0.0, end: -0.2), weight: 20),
|
||||
TweenSequenceItem(tween: Tween(begin: -0.2, end: 0.15), weight: 20),
|
||||
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
|
||||
TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20),
|
||||
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
|
||||
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _rotationAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.rotate(
|
||||
angle: _rotationAnimation.value,
|
||||
alignment: Alignment.topCenter,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpinIcon extends StatefulWidget {
|
||||
final Widget child;
|
||||
const SpinIcon({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<SpinIcon> createState() => _SpinIconState();
|
||||
}
|
||||
|
||||
class _SpinIconState extends State<SpinIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _rotationAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
);
|
||||
_rotationAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 0.5,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RotationTransition(turns: _rotationAnimation, child: widget.child);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -9,14 +8,16 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
final List<Track> tracks;
|
||||
final String? playlistId; // Deezer playlist ID for fetching tracks
|
||||
final String? playlistId;
|
||||
|
||||
const PlaylistScreen({
|
||||
super.key,
|
||||
@@ -436,6 +437,18 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
// Check local library for duplicate detection
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
|
||||
final isInLocalLibrary = showLocalLibraryIndicator
|
||||
? ref.watch(localLibraryProvider.select((state) =>
|
||||
state.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
)))
|
||||
: false;
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
@@ -456,22 +469,51 @@ leading: track.coverUrl != null
|
||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
|
||||
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))),
|
||||
if (isInLocalLibrary) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 3),
|
||||
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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, required bool isInLocalLibrary}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInLocalLibrary) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
final exists = await fileExists(historyItem.filePath);
|
||||
if (exists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
|
||||
}
|
||||
@@ -491,6 +533,7 @@ leading: track.coverUrl != null
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required bool isInLocalLibrary,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 44.0;
|
||||
@@ -498,7 +541,7 @@ leading: track.coverUrl != null
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||
);
|
||||
} else if (isFinalizing) {
|
||||
|
||||
+1467
-286
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,15 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
const DownloadSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DownloadSettingsPage> createState() => _DownloadSettingsPageState();
|
||||
ConsumerState<DownloadSettingsPage> createState() =>
|
||||
_DownloadSettingsPageState();
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
@@ -92,7 +94,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
final isTidalService = settings.defaultService == 'tidal';
|
||||
|
||||
@@ -102,43 +104,43 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.downloadTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
title: Text(
|
||||
context.l10n.downloadTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionService),
|
||||
@@ -157,7 +159,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionAudioQuality,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -165,7 +169,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.downloadAskBeforeDownload,
|
||||
subtitle: isBuiltInService
|
||||
subtitle: isBuiltInService
|
||||
? context.l10n.downloadAskQualitySubtitle
|
||||
: 'Select a built-in service to enable',
|
||||
value: settings.askQualityBeforeDownload,
|
||||
@@ -174,7 +178,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
.read(settingsProvider.notifier)
|
||||
.setAskQualityBeforeDownload(value),
|
||||
),
|
||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||
if (!settings.askQualityBeforeDownload &&
|
||||
isBuiltInService) ...[
|
||||
_QualityOption(
|
||||
title: context.l10n.qualityFlacLossless,
|
||||
subtitle: context.l10n.qualityFlacLosslessSubtitle,
|
||||
@@ -204,7 +209,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
if (isTidalService)
|
||||
_QualityOption(
|
||||
title: 'Lossy 320kbps',
|
||||
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
isSelected: settings.audioQuality == 'HIGH',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -215,8 +222,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SettingsItem(
|
||||
icon: Icons.tune,
|
||||
title: 'Lossy Format',
|
||||
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
|
||||
onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat),
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
onTap: () => _showTidalHighFormatPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -234,43 +247,46 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Select Tidal, Qobuz, or Amazon above to configure quality',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionFileSettings,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
@@ -309,7 +325,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: context.l10n.downloadAlbumFolderStructure,
|
||||
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
|
||||
subtitle: _getAlbumFolderStructureLabel(
|
||||
settings.albumFolderStructure,
|
||||
),
|
||||
onTap: () => _showAlbumFolderStructurePicker(
|
||||
context,
|
||||
ref,
|
||||
@@ -334,10 +352,47 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
),
|
||||
),
|
||||
|
||||
// Download Network Mode
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.wifi,
|
||||
title: context.l10n.settingsDownloadNetwork,
|
||||
subtitle: settings.downloadNetworkMode == 'wifi_only'
|
||||
? context.l10n.settingsDownloadNetworkWifiOnly
|
||||
: context.l10n.settingsDownloadNetworkAny,
|
||||
onTap: () => _showNetworkModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.downloadNetworkMode,
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.file_download_outlined,
|
||||
title: context.l10n.settingsAutoExportFailed,
|
||||
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
|
||||
value: settings.autoExportFailedDownloads,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAutoExportFailedDownloads(value);
|
||||
},
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// All Files Access section (Android 13+ only)
|
||||
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionStorageAccess),
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionStorageAccess,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -376,36 +431,15 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.allFilesAccessDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
|
||||
// Auto Export Failed Downloads
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.file_download_outlined,
|
||||
title: context.l10n.settingsAutoExportFailed,
|
||||
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
|
||||
value: settings.autoExportFailedDownloads,
|
||||
onChanged: (value) {
|
||||
ref.read(settingsProvider.notifier).setAutoExportFailedDownloads(value);
|
||||
},
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
@@ -429,7 +463,11 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
|
||||
void _showAlbumFolderStructurePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
@@ -440,9 +478,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(context.l10n.albumFolderArtistAlbum),
|
||||
subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle),
|
||||
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
|
||||
trailing: current == 'artist_album'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -450,9 +492,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
leading: const Icon(Icons.calendar_today_outlined),
|
||||
title: Text(context.l10n.albumFolderArtistYearAlbum),
|
||||
subtitle: Text(context.l10n.albumFolderArtistYearAlbumSubtitle),
|
||||
trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null,
|
||||
trailing: current == 'artist_year_album'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('artist_year_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -460,9 +506,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
leading: const Icon(Icons.album_outlined),
|
||||
title: Text(context.l10n.albumFolderAlbumOnly),
|
||||
subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle),
|
||||
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
|
||||
trailing: current == 'album_only'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('album_only');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -470,19 +520,29 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
leading: const Icon(Icons.event_outlined),
|
||||
title: Text(context.l10n.albumFolderYearAlbum),
|
||||
subtitle: Text(context.l10n.albumFolderYearAlbumSubtitle),
|
||||
trailing: current == 'year_album' ? const Icon(Icons.check) : null,
|
||||
trailing: current == 'year_album'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('year_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outlined),
|
||||
title: Text(context.l10n.albumFolderArtistAlbumSingles),
|
||||
subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle),
|
||||
trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null,
|
||||
subtitle: Text(
|
||||
context.l10n.albumFolderArtistAlbumSinglesSubtitle,
|
||||
),
|
||||
trailing: current == 'artist_album_singles'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('artist_album_singles');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -625,7 +685,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
@@ -671,13 +731,123 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
if (Platform.isIOS) {
|
||||
_showIOSDirectoryOptions(context, ref);
|
||||
} else {
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
_showAndroidDirectoryOptions(context, ref);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _getDefaultAndroidDirectory() async {
|
||||
final directMusicPath = '/storage/emulated/0/Music/SpotiFLAC';
|
||||
try {
|
||||
final musicDir = Directory(directMusicPath);
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
}
|
||||
return musicDir.path;
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final externalDir = await getExternalStorageDirectory();
|
||||
if (externalDir != null) {
|
||||
final musicDir = Directory(
|
||||
'${externalDir.parent.parent.parent.parent.path}/Music/SpotiFLAC',
|
||||
);
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
}
|
||||
return musicDir.path;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final fallbackDir = Directory('${appDir.path}/SpotiFLAC');
|
||||
if (!await fallbackDir.exists()) {
|
||||
await fallbackDir.create(recursive: true);
|
||||
}
|
||||
return fallbackDir.path;
|
||||
}
|
||||
|
||||
void _showAndroidDirectoryOptions(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final isSafMode =
|
||||
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Download Location',
|
||||
style: Theme.of(
|
||||
ctx,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Choose storage mode for downloaded files.',
|
||||
style: Theme.of(ctx).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||
title: const Text('App folder (non-SAF)'),
|
||||
subtitle: const Text('Use default Music/SpotiFLAC path'),
|
||||
trailing: !isSafMode ? const Icon(Icons.check) : null,
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final defaultDir = await _getDefaultAndroidDirectory();
|
||||
final notifier = ref.read(settingsProvider.notifier);
|
||||
notifier.setStorageMode('app');
|
||||
notifier.setDownloadDirectory(defaultDir);
|
||||
notifier.setDownloadTreeUri('');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder_open, color: colorScheme.primary),
|
||||
title: const Text('SAF folder'),
|
||||
subtitle: const Text(
|
||||
'Pick folder via Android Storage Access Framework',
|
||||
),
|
||||
trailing: isSafMode ? const Icon(Icons.check) : null,
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await PlatformBridge.pickSafTree();
|
||||
if (result != null) {
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
final displayName = result['display_name'] as String? ?? '';
|
||||
if (treeUri.isNotEmpty) {
|
||||
ref.read(settingsProvider.notifier).setStorageMode('saf');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadTreeUri(
|
||||
treeUri,
|
||||
displayName: displayName.isNotEmpty
|
||||
? displayName
|
||||
: treeUri,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
@@ -722,7 +892,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
ListTile(
|
||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||
title: Text(context.l10n.setupChooseFromFiles),
|
||||
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||
@@ -733,7 +903,8 @@ ListTile(
|
||||
if (result != null) {
|
||||
// iOS: Check if user selected iCloud Drive (not accessible by Go backend)
|
||||
if (Platform.isIOS) {
|
||||
final isICloudPath = result.contains('Mobile Documents') ||
|
||||
final isICloudPath =
|
||||
result.contains('Mobile Documents') ||
|
||||
result.contains('CloudDocs') ||
|
||||
result.contains('com~apple~CloudDocs');
|
||||
if (isICloudPath) {
|
||||
@@ -890,6 +1061,8 @@ ListTile(
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
return 'MP3 320kbps';
|
||||
case 'opus_256':
|
||||
return 'Opus 256kbps';
|
||||
case 'opus_128':
|
||||
return 'Opus 128kbps';
|
||||
default:
|
||||
@@ -936,19 +1109,111 @@ ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: const Text('MP3 320kbps'),
|
||||
subtitle: const Text('Best compatibility, ~10MB per track'),
|
||||
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
trailing: current == 'mp3_320'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('mp3_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: const Text('Opus 256kbps'),
|
||||
subtitle: const Text('Best quality Opus, ~8MB per track'),
|
||||
trailing: current == 'opus_256'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_256');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: const Text('Opus 128kbps'),
|
||||
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
subtitle: const Text('Smallest size, ~4MB per track'),
|
||||
trailing: current == 'opus_128'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_128');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showNetworkModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.settingsDownloadNetwork,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.settingsDownloadNetworkSubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.signal_cellular_alt),
|
||||
title: Text(context.l10n.settingsDownloadNetworkAny),
|
||||
subtitle: const Text('WiFi + Mobile Data'),
|
||||
trailing: current == 'any'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadNetworkMode('any');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wifi),
|
||||
title: Text(context.l10n.settingsDownloadNetworkWifiOnly),
|
||||
subtitle: const Text('Pause downloads on mobile data'),
|
||||
trailing: current == 'wifi_only'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDownloadNetworkMode('wifi_only');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1005,7 +1270,9 @@ ListTile(
|
||||
example: 'SpotiFLAC/Track.flac',
|
||||
isSelected: current == 'none',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('none');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('none');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1015,7 +1282,9 @@ ListTile(
|
||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||
isSelected: current == 'artist',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('artist');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1025,7 +1294,9 @@ ListTile(
|
||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||
isSelected: current == 'album',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1035,7 +1306,9 @@ ListTile(
|
||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||
isSelected: current == 'artist_album',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -1059,18 +1332,22 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
|
||||
final isExtensionService = ![
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'amazon',
|
||||
].contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
: true;
|
||||
|
||||
|
||||
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
|
||||
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
@@ -1095,7 +1372,9 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
icon: Icons.shopping_bag,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
isDisabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1132,11 +1411,15 @@ class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final bool isDisabled;
|
||||
final String? disabledReason;
|
||||
const _ServiceChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.isDisabled = false,
|
||||
this.disabledReason,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1151,37 +1434,66 @@ class _ServiceChip extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final disabledColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.02),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerLow;
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Tooltip(
|
||||
message: isDisabled && disabledReason != null ? disabledReason! : '',
|
||||
child: Material(
|
||||
color: isDisabled
|
||||
? disabledColor
|
||||
: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
child: InkWell(
|
||||
onTap: isDisabled ? null : onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected && !isDisabled
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (isDisabled && disabledReason != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
disabledReason!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.38),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class LibrarySettingsPage extends ConsumerStatefulWidget {
|
||||
const LibrarySettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LibrarySettingsPage> createState() =>
|
||||
_LibrarySettingsPageState();
|
||||
}
|
||||
|
||||
class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasStoragePermission = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initDeviceInfo();
|
||||
}
|
||||
|
||||
Future<void> _initDeviceInfo() async {
|
||||
if (Platform.isAndroid) {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
final sdkVersion = androidInfo.version.sdkInt;
|
||||
|
||||
// Check appropriate storage permission based on Android version
|
||||
bool hasPermission;
|
||||
if (sdkVersion >= 30) {
|
||||
hasPermission = await Permission.manageExternalStorage.isGranted;
|
||||
} else {
|
||||
hasPermission = await Permission.storage.isGranted;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_androidSdkVersion = sdkVersion;
|
||||
_hasStoragePermission = hasPermission;
|
||||
});
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS doesn't need explicit storage permission for app documents
|
||||
setState(() => _hasStoragePermission = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _requestStoragePermission() async {
|
||||
if (Platform.isIOS) return true;
|
||||
|
||||
PermissionStatus status;
|
||||
if (_androidSdkVersion >= 30) {
|
||||
status = await Permission.manageExternalStorage.request();
|
||||
} else {
|
||||
status = await Permission.storage.request();
|
||||
}
|
||||
|
||||
if (status.isGranted) {
|
||||
setState(() => _hasStoragePermission = true);
|
||||
return true;
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
if (mounted) {
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.libraryStorageAccessRequired),
|
||||
content: Text(context.l10n.libraryStorageAccessMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.setupOpenSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (shouldOpen == true) {
|
||||
await openAppSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _pickLibraryFolder() async {
|
||||
// Request permission first
|
||||
if (!_hasStoragePermission) {
|
||||
final granted = await _requestStoragePermission();
|
||||
if (!granted) return;
|
||||
}
|
||||
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final libraryPath = settings.localLibraryPath;
|
||||
|
||||
if (libraryPath.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.libraryScanSelectFolderFirst)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!libraryPath.startsWith('content://') &&
|
||||
!await Directory(libraryPath).exists()) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.libraryFolderNotExist)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(localLibraryProvider.notifier).startScan(libraryPath);
|
||||
}
|
||||
|
||||
Future<void> _cancelScan() async {
|
||||
await ref.read(localLibraryProvider.notifier).cancelScan();
|
||||
}
|
||||
|
||||
Future<void> _clearLibrary() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.libraryClearConfirmTitle),
|
||||
content: Text(context.l10n.libraryClearConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(context.l10n.dialogClear),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref.read(localLibraryProvider.notifier).clearLibrary();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.libraryCleared)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupMissingFiles() async {
|
||||
final removed = await ref
|
||||
.read(localLibraryProvider.notifier)
|
||||
.cleanupMissingFiles();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.libraryRemovedMissingFiles(removed)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final libraryState = ref.watch(localLibraryProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
context.l10n.libraryTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: _LibraryHeroCard(
|
||||
itemCount: libraryState.items.length,
|
||||
isScanning: libraryState.isScanning,
|
||||
scanProgress: libraryState.scanProgress,
|
||||
scanCurrentFile: libraryState.scanCurrentFile,
|
||||
scanTotalFiles: libraryState.scanTotalFiles,
|
||||
lastScannedAt: libraryState.lastScannedAt,
|
||||
),
|
||||
),
|
||||
|
||||
// Scan Settings Section
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.libraryScanSettings,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: context.l10n.libraryEnableLocalLibrary,
|
||||
subtitle: settings.localLibraryEnabled
|
||||
? context.l10n.libraryEnableLocalLibrarySubtitle
|
||||
: context.l10n.extensionsDisabled,
|
||||
value: settings.localLibraryEnabled,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryEnabled(value),
|
||||
),
|
||||
Opacity(
|
||||
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: context.l10n.libraryFolder,
|
||||
subtitle: settings.localLibraryPath.isEmpty
|
||||
? context.l10n.libraryFolderHint
|
||||
: settings.localLibraryPath,
|
||||
onTap: settings.localLibraryEnabled
|
||||
? _pickLibraryFolder
|
||||
: null,
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.content_copy_outlined,
|
||||
title: context.l10n.libraryShowDuplicateIndicator,
|
||||
subtitle: settings.localLibraryShowDuplicates
|
||||
? context.l10n.libraryShowDuplicateIndicatorSubtitle
|
||||
: context.l10n.extensionsDisabled,
|
||||
value: settings.localLibraryShowDuplicates,
|
||||
enabled: settings.localLibraryEnabled,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryShowDuplicates(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Scan Actions Section
|
||||
if (settings.localLibraryEnabled) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.libraryActions),
|
||||
),
|
||||
if (libraryState.scanWasCancelled)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Scan cancelled',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'You can retry the scan when ready.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _startScan,
|
||||
child: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
if (libraryState.isScanning)
|
||||
_ScanProgressTile(
|
||||
progress: libraryState.scanProgress,
|
||||
currentFile: libraryState.scanCurrentFile,
|
||||
totalFiles: libraryState.scanTotalFiles,
|
||||
onCancel: _cancelScan,
|
||||
)
|
||||
else
|
||||
Opacity(
|
||||
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.refresh,
|
||||
title: context.l10n.libraryScan,
|
||||
subtitle: settings.localLibraryPath.isEmpty
|
||||
? context.l10n.libraryScanSelectFolderFirst
|
||||
: context.l10n.libraryScanSubtitle,
|
||||
onTap: settings.localLibraryPath.isNotEmpty
|
||||
? _startScan
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
title: context.l10n.libraryCleanupMissingFiles,
|
||||
subtitle: context.l10n.libraryCleanupMissingFilesSubtitle,
|
||||
onTap: libraryState.items.isNotEmpty
|
||||
? _cleanupMissingFiles
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.delete_outline,
|
||||
title: context.l10n.libraryClear,
|
||||
subtitle: context.l10n.libraryClearSubtitle,
|
||||
onTap: libraryState.items.isNotEmpty
|
||||
? _clearLibrary
|
||||
: null,
|
||||
showDivider: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Info Section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.libraryAbout,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.libraryAboutDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer
|
||||
.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryHeroCard extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final bool isScanning;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
final DateTime? lastScannedAt;
|
||||
|
||||
const _LibraryHeroCard({
|
||||
required this.itemCount,
|
||||
required this.isScanning,
|
||||
required this.scanProgress,
|
||||
this.scanCurrentFile,
|
||||
required this.scanTotalFiles,
|
||||
this.lastScannedAt,
|
||||
});
|
||||
|
||||
String _formatLastScanned(BuildContext context) {
|
||||
if (lastScannedAt == null) return context.l10n.libraryLastScannedNever;
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastScannedAt!);
|
||||
|
||||
if (diff.inMinutes < 1) return context.l10n.timeJustNow;
|
||||
if (diff.inHours < 1) return context.l10n.timeMinutesAgo(diff.inMinutes);
|
||||
if (diff.inDays < 1) return context.l10n.timeHoursAgo(diff.inHours);
|
||||
if (diff.inDays < 7) return context.l10n.dateDaysAgo(diff.inDays);
|
||||
|
||||
return '${lastScannedAt!.day}/${lastScannedAt!.month}/${lastScannedAt!.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: [
|
||||
if (!isDark)
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background decorative elements
|
||||
Positioned(
|
||||
right: -20,
|
||||
top: -20,
|
||||
child: Icon(
|
||||
Icons.library_music,
|
||||
size: 200,
|
||||
color: colorScheme.primary.withValues(alpha: 0.05),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: -40,
|
||||
bottom: -40,
|
||||
child: Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
isScanning ? Icons.sync : Icons.music_note,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isScanning)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Scanning...',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
itemCount.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
height: 1.0,
|
||||
letterSpacing: -2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n
|
||||
.libraryTracksCount(itemCount)
|
||||
.replaceAll(itemCount.toString(), '')
|
||||
.trim(), // Getting just the label part if possible, or just use the full string if not
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (isScanning && scanCurrentFile != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
value: scanProgress / 100,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
size: 14,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.libraryLastScanned(
|
||||
_formatLastScanned(context),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanProgressTile extends StatelessWidget {
|
||||
final double progress;
|
||||
final String? currentFile;
|
||||
final int totalFiles;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _ScanProgressTile({
|
||||
required this.progress,
|
||||
this.currentFile,
|
||||
required this.totalFiles,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.scanner, color: colorScheme.primary),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.libraryScanning,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.libraryScanProgress(
|
||||
progress.toStringAsFixed(0),
|
||||
totalFiles,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onCancel,
|
||||
child: Text(context.l10n.actionCancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: progress / 100,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
if (currentFile != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
currentFile!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,21 +65,23 @@ class _LogScreenState extends State<LogScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _copyLogs() {
|
||||
final logs = LogBuffer().export();
|
||||
void _copyLogs() async {
|
||||
final logs = await LogBuffer().exportWithDeviceInfo();
|
||||
Clipboard.setData(ClipboardData(text: logs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.logCopied),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.logCopied),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _shareLogs() {
|
||||
final logs = LogBuffer().export();
|
||||
void _shareLogs() async {
|
||||
final logs = await LogBuffer().exportWithDeviceInfo();
|
||||
SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs'));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||
@@ -73,6 +74,12 @@ class SettingsTab extends ConsumerWidget {
|
||||
subtitle: l10n.settingsDownloadSubtitle,
|
||||
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: l10n.settingsLocalLibrary,
|
||||
subtitle: l10n.settingsLocalLibrarySubtitle,
|
||||
onTap: () => _navigateTo(context, const LibrarySettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: l10n.settingsOptions,
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
class SetupScreen extends ConsumerStatefulWidget {
|
||||
const SetupScreen({super.key});
|
||||
@@ -22,6 +23,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
bool _storagePermissionGranted = false;
|
||||
bool _notificationPermissionGranted = false;
|
||||
String? _selectedDirectory;
|
||||
String? _selectedTreeUri;
|
||||
bool _isLoading = false;
|
||||
int _androidSdkVersion = 0;
|
||||
|
||||
@@ -246,13 +248,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (Platform.isIOS) {
|
||||
await _showIOSDirectoryOptions();
|
||||
} else {
|
||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: context.l10n.setupSelectDownloadFolder,
|
||||
);
|
||||
final result = await PlatformBridge.pickSafTree();
|
||||
if (result != null) {
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
final displayName = result['display_name'] as String? ?? '';
|
||||
if (treeUri.isNotEmpty) {
|
||||
setState(() {
|
||||
_selectedTreeUri = treeUri;
|
||||
_selectedDirectory = displayName.isNotEmpty ? displayName : treeUri;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedDirectory != null) {
|
||||
setState(() => _selectedDirectory = selectedDirectory);
|
||||
} else {
|
||||
if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) {
|
||||
final defaultDir = await _getDefaultDirectory();
|
||||
if (mounted) {
|
||||
final useDefault = await showDialog<bool>(
|
||||
@@ -268,7 +276,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
);
|
||||
|
||||
if (useDefault == true) {
|
||||
setState(() => _selectedDirectory = defaultDir);
|
||||
setState(() {
|
||||
_selectedTreeUri = '';
|
||||
_selectedDirectory = defaultDir;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,12 +398,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final dir = Directory(_selectedDirectory!);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
if (!Platform.isAndroid || _selectedTreeUri == null || _selectedTreeUri!.isEmpty) {
|
||||
final dir = Directory(_selectedDirectory!);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
ref.read(settingsProvider.notifier).setStorageMode('app');
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
||||
ref.read(settingsProvider.notifier).setDownloadTreeUri('');
|
||||
} else {
|
||||
ref.read(settingsProvider.notifier).setStorageMode('saf');
|
||||
ref.read(settingsProvider.notifier).setDownloadTreeUri(
|
||||
_selectedTreeUri!,
|
||||
displayName: _selectedDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
||||
|
||||
if (_useSpotifyApi &&
|
||||
_clientIdController.text.trim().isNotEmpty &&
|
||||
|
||||
@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/palette_service.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
@@ -14,9 +14,11 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
class TrackMetadataScreen extends ConsumerStatefulWidget {
|
||||
final DownloadHistoryItem item;
|
||||
final DownloadHistoryItem? item;
|
||||
final LocalLibraryItem? localItem;
|
||||
|
||||
const TrackMetadataScreen({super.key, required this.item});
|
||||
const TrackMetadataScreen({super.key, this.item, this.localItem})
|
||||
: assert(item != null || localItem != null, 'Either item or localItem must be provided');
|
||||
|
||||
@override
|
||||
ConsumerState<TrackMetadataScreen> createState() => _TrackMetadataScreenState();
|
||||
@@ -88,7 +90,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _extractDominantColor() async {
|
||||
final coverUrl = widget.item.coverUrl;
|
||||
// For local items with cover path, extract from file
|
||||
if (_isLocalItem && _localCoverPath != null && _localCoverPath!.isNotEmpty) {
|
||||
final color = await PaletteService.instance.extractDominantColorFromFile(_localCoverPath!);
|
||||
if (mounted && color != null && color != _dominantColor) {
|
||||
setState(() => _dominantColor = color);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final coverUrl = _coverUrl;
|
||||
if (coverUrl == null) return;
|
||||
|
||||
// Check cache first
|
||||
final cachedColor = PaletteService.instance.getCached(coverUrl);
|
||||
@@ -107,7 +119,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _checkFile() async {
|
||||
var filePath = widget.item.filePath;
|
||||
var filePath = _filePath;
|
||||
if (filePath.startsWith('EXISTS:')) {
|
||||
filePath = filePath.substring(7);
|
||||
}
|
||||
@@ -115,9 +127,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool exists = false;
|
||||
int? size;
|
||||
try {
|
||||
final stat = await FileStat.stat(filePath);
|
||||
exists = stat.type != FileSystemEntityType.notFound;
|
||||
if (exists) {
|
||||
final stat = await fileStat(filePath);
|
||||
if (stat != null) {
|
||||
exists = true;
|
||||
size = stat.size;
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -134,25 +146,38 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
DownloadHistoryItem get item => widget.item;
|
||||
String get trackName => item.trackName;
|
||||
String get artistName => item.artistName;
|
||||
String get albumName => item.albumName;
|
||||
String? get albumArtist => _normalizeOptionalString(item.albumArtist);
|
||||
int? get trackNumber => item.trackNumber;
|
||||
int? get discNumber => item.discNumber;
|
||||
String? get releaseDate => item.releaseDate;
|
||||
String? get isrc => item.isrc;
|
||||
String? get genre => item.genre;
|
||||
String? get label => item.label;
|
||||
String? get copyright => item.copyright;
|
||||
bool get _isLocalItem => widget.localItem != null;
|
||||
DownloadHistoryItem? get _downloadItem => widget.item;
|
||||
LocalLibraryItem? get _localLibraryItem => widget.localItem;
|
||||
|
||||
String get _itemId => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
|
||||
String get trackName => _isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName;
|
||||
String get artistName => _isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName;
|
||||
String get albumName => _isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName;
|
||||
String? get albumArtist => _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist);
|
||||
int? get trackNumber => _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber;
|
||||
int? get discNumber => _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber;
|
||||
String? get releaseDate => _isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate;
|
||||
String? get isrc => _isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc;
|
||||
String? get genre => _isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre;
|
||||
String? get label => _isLocalItem ? null : _downloadItem!.label;
|
||||
String? get copyright => _isLocalItem ? null : _downloadItem!.copyright;
|
||||
int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
|
||||
int? get bitDepth => _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
|
||||
int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
|
||||
|
||||
String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
|
||||
String? get _coverUrl => _isLocalItem ? null : _downloadItem!.coverUrl;
|
||||
String? get _localCoverPath => _isLocalItem ? _localLibraryItem!.coverPath : null;
|
||||
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
|
||||
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
|
||||
DateTime get _addedAt => _isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt;
|
||||
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
|
||||
|
||||
String get cleanFilePath {
|
||||
final path = item.filePath;
|
||||
final path = _filePath;
|
||||
return path.startsWith('EXISTS:') ? path.substring(7) : path;
|
||||
}
|
||||
int? get bitDepth => item.bitDepth;
|
||||
int? get sampleRate => item.sampleRate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -287,7 +312,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Hero(
|
||||
tag: 'cover_${item.id}',
|
||||
tag: 'cover_$_itemId',
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
@@ -303,9 +328,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: item.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
child: _coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
@@ -318,14 +343,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
: _localCoverPath != null && _localCoverPath!.isNotEmpty
|
||||
? Image.file(
|
||||
File(_localCoverPath!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -448,11 +478,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
_buildMetadataGrid(context, colorScheme),
|
||||
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
||||
if (_spotifyId != null && _spotifyId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final isDeezer = item.spotifyId!.contains('deezer');
|
||||
final isDeezer = _spotifyId!.contains('deezer');
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () => _openServiceUrl(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
@@ -474,10 +504,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _openServiceUrl(BuildContext context) async {
|
||||
if (item.spotifyId == null) return;
|
||||
if (_spotifyId == null) return;
|
||||
|
||||
final isDeezer = item.spotifyId!.contains('deezer');
|
||||
final rawId = item.spotifyId!.replaceAll('deezer:', '');
|
||||
final isDeezer = _spotifyId!.contains('deezer');
|
||||
final rawId = _spotifyId!.replaceAll('deezer:', '');
|
||||
|
||||
final webUrl = isDeezer
|
||||
? 'https://www.deezer.com/track/$rawId'
|
||||
@@ -519,12 +549,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||
// Determine audio quality string - prefer stored quality from download
|
||||
String? audioQualityStr;
|
||||
final fileName = item.filePath.split('/').last;
|
||||
final fileName = _filePath.split('/').last;
|
||||
final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : '';
|
||||
|
||||
// Use stored quality from download history if available
|
||||
if (item.quality != null && item.quality!.isNotEmpty) {
|
||||
audioQualityStr = item.quality;
|
||||
if (_quality != null && _quality!.isNotEmpty) {
|
||||
audioQualityStr = _quality;
|
||||
} else if (bitDepth != null && sampleRate != null) {
|
||||
// Fallback for FLAC files without stored quality
|
||||
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
||||
@@ -550,8 +580,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
|
||||
if (discNumber != null && discNumber! > 0)
|
||||
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
|
||||
if (item.duration != null)
|
||||
_MetadataItem(context.l10n.trackDuration, _formatDuration(item.duration!)),
|
||||
if (duration != null)
|
||||
_MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)),
|
||||
if (audioQualityStr != null)
|
||||
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
|
||||
if (releaseDate != null && releaseDate!.isNotEmpty)
|
||||
@@ -566,16 +596,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_MetadataItem('ISRC', isrc!),
|
||||
];
|
||||
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||
final isDeezer = item.spotifyId!.contains('deezer');
|
||||
final cleanId = item.spotifyId!.replaceAll('deezer:', '');
|
||||
if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) {
|
||||
final isDeezer = _spotifyId!.contains('deezer');
|
||||
final cleanId = _spotifyId!.replaceAll('deezer:', '');
|
||||
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
|
||||
}
|
||||
|
||||
items.addAll([
|
||||
_MetadataItem(context.l10n.trackMetadataService, item.service.toUpperCase()),
|
||||
_MetadataItem(context.l10n.trackDownloaded, _formatFullDate(item.downloadedAt)),
|
||||
]);
|
||||
items.add(_MetadataItem(context.l10n.trackMetadataService, _service.toUpperCase()));
|
||||
items.add(_MetadataItem(
|
||||
context.l10n.trackDownloaded,
|
||||
_formatFullDate(_addedAt),
|
||||
));
|
||||
|
||||
return Column(
|
||||
children: items.map((metadata) {
|
||||
@@ -728,20 +759,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getServiceColor(item.service, colorScheme),
|
||||
color: _getServiceColor(_service, colorScheme),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getServiceIcon(item.service),
|
||||
_getServiceIcon(_service),
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item.service.toUpperCase(),
|
||||
_service.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -943,14 +974,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
try {
|
||||
// Convert duration from seconds to milliseconds
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final durationMs = (duration ?? 0) * 1000;
|
||||
|
||||
// First, check if lyrics are embedded in the file
|
||||
if (_fileExists) {
|
||||
final embeddedResult = await PlatformBridge.getLyricsLRC(
|
||||
'',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
trackName,
|
||||
artistName,
|
||||
filePath: cleanFilePath,
|
||||
durationMs: 0,
|
||||
).timeout(const Duration(seconds: 5), onTimeout: () => '');
|
||||
@@ -971,9 +1002,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
// No embedded lyrics, fetch from online
|
||||
final result = await PlatformBridge.getLyricsLRC(
|
||||
item.spotifyId ?? '',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
_spotifyId ?? '',
|
||||
trackName,
|
||||
artistName,
|
||||
filePath: null, // Don't check file again
|
||||
durationMs: durationMs,
|
||||
).timeout(
|
||||
@@ -1177,17 +1208,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final file = File(cleanFilePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
if (_isLocalItem) {
|
||||
// For local items, just delete the file
|
||||
try {
|
||||
await deleteFile(cleanFilePath);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to delete file: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to delete file: $e');
|
||||
// Also remove from local library database
|
||||
// ref.read(localLibraryProvider.notifier).removeItem(_localLibraryItem!.id);
|
||||
} else {
|
||||
// Existing download history deletion logic
|
||||
try {
|
||||
await deleteFile(cleanFilePath);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to delete file: $e');
|
||||
}
|
||||
|
||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(_downloadItem!.id);
|
||||
}
|
||||
|
||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
@@ -1202,13 +1242,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||
try {
|
||||
final mimeType = audioMimeTypeForPath(filePath);
|
||||
final result = await OpenFilex.open(filePath, type: mimeType);
|
||||
if (result.type != ResultType.done && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackCannotOpen(result.message))),
|
||||
);
|
||||
}
|
||||
await openFile(filePath);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1229,8 +1263,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _shareFile(BuildContext context) async {
|
||||
final file = File(cleanFilePath);
|
||||
if (!await file.exists()) {
|
||||
String sharePath = cleanFilePath;
|
||||
if (!await fileExists(sharePath)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarFileNotFound)),
|
||||
@@ -1238,11 +1272,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isContentUri(sharePath)) {
|
||||
final tempPath = await PlatformBridge.copyContentUriToTemp(sharePath);
|
||||
if (tempPath == null || tempPath.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('Failed to prepare file for sharing'))),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
sharePath = tempPath;
|
||||
}
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(cleanFilePath)],
|
||||
text: '${item.trackName} - ${item.artistName}',
|
||||
files: [XFile(sharePath)],
|
||||
text: '$trackName - $artistName',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ class CoverCacheManager {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// Check if cache manager is initialized
|
||||
static bool get isInitialized => _initialized && _instance != null;
|
||||
|
||||
static Future<void> initialize() async {
|
||||
@@ -62,8 +61,6 @@ class CoverCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cached cover images.
|
||||
/// Returns the number of files deleted.
|
||||
static Future<void> clearCache() async {
|
||||
if (!_initialized || _instance == null) return;
|
||||
await _instance!.emptyCache();
|
||||
@@ -98,7 +95,6 @@ class CoverCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics about the cover image cache
|
||||
class CacheStats {
|
||||
final int fileCount;
|
||||
final int totalSizeBytes;
|
||||
|
||||
+152
-108
@@ -10,12 +10,28 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
class FFmpegService {
|
||||
static String _buildOutputPath(String inputPath, String extension) {
|
||||
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
|
||||
final inputFile = File(inputPath);
|
||||
final dir = inputFile.parent.path;
|
||||
final filename = inputFile.uri.pathSegments.last;
|
||||
final dotIndex = filename.lastIndexOf('.');
|
||||
final baseName = dotIndex > 0 ? filename.substring(0, dotIndex) : filename;
|
||||
var outputPath = '$dir${Platform.pathSeparator}$baseName$normalizedExt';
|
||||
|
||||
if (outputPath == inputPath) {
|
||||
outputPath =
|
||||
'$dir${Platform.pathSeparator}${baseName}_converted$normalizedExt';
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
static Future<FFmpegResult> _execute(String command) async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
final output = await session.getOutput() ?? '';
|
||||
|
||||
|
||||
return FFmpegResult(
|
||||
success: ReturnCode.isSuccess(returnCode),
|
||||
returnCode: returnCode?.getValue() ?? -1,
|
||||
@@ -28,7 +44,7 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
|
||||
final command =
|
||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||
@@ -59,10 +75,10 @@ class FFmpegService {
|
||||
bitrateValue = '${parts[1]}k';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final extension = format == 'opus' ? '.opus' : '.mp3';
|
||||
final outputPath = inputPath.replaceAll('.m4a', extension);
|
||||
|
||||
final outputPath = _buildOutputPath(inputPath, extension);
|
||||
|
||||
String command;
|
||||
if (format == 'opus') {
|
||||
command =
|
||||
@@ -92,7 +108,7 @@ class FFmpegService {
|
||||
String bitrate = '320k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = inputPath.replaceAll('.flac', '.mp3');
|
||||
final outputPath = _buildOutputPath(inputPath, '.mp3');
|
||||
|
||||
final command =
|
||||
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||
@@ -117,7 +133,7 @@ class FFmpegService {
|
||||
String bitrate = '128k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final outputPath = inputPath.replaceAll('.flac', '.opus');
|
||||
final outputPath = _buildOutputPath(inputPath, '.opus');
|
||||
|
||||
final command =
|
||||
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
@@ -150,15 +166,27 @@ class FFmpegService {
|
||||
bitrateValue = '${parts[1]}k';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
switch (format.toLowerCase()) {
|
||||
case 'opus':
|
||||
final opusBitrate = bitrate?.startsWith('opus_') == true ? bitrateValue : '128k';
|
||||
return convertFlacToOpus(inputPath, bitrate: opusBitrate, deleteOriginal: deleteOriginal);
|
||||
final opusBitrate = bitrate?.startsWith('opus_') == true
|
||||
? bitrateValue
|
||||
: '128k';
|
||||
return convertFlacToOpus(
|
||||
inputPath,
|
||||
bitrate: opusBitrate,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
case 'mp3':
|
||||
default:
|
||||
final mp3Bitrate = bitrate?.startsWith('mp3_') == true ? bitrateValue : '320k';
|
||||
return convertFlacToMp3(inputPath, bitrate: mp3Bitrate, deleteOriginal: deleteOriginal);
|
||||
final mp3Bitrate = bitrate?.startsWith('mp3_') == true
|
||||
? bitrateValue
|
||||
: '320k';
|
||||
return convertFlacToMp3(
|
||||
inputPath,
|
||||
bitrate: mp3Bitrate,
|
||||
deleteOriginal: deleteOriginal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +196,10 @@ class FFmpegService {
|
||||
String bitrate = '256k',
|
||||
}) async {
|
||||
final dir = File(inputPath).parent.path;
|
||||
final baseName =
|
||||
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||
final baseName = inputPath
|
||||
.split(Platform.pathSeparator)
|
||||
.last
|
||||
.replaceAll('.flac', '');
|
||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||
|
||||
await Directory(outputDir).create(recursive: true);
|
||||
@@ -220,16 +250,16 @@ class FFmpegService {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
|
||||
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$flacPath" ');
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v copy ');
|
||||
@@ -237,18 +267,18 @@ class FFmpegService {
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg command: $command');
|
||||
|
||||
@@ -258,20 +288,19 @@ class FFmpegService {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
final originalFile = File(flacPath);
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(flacPath);
|
||||
await tempFile.delete();
|
||||
|
||||
return flacPath;
|
||||
} else {
|
||||
_log.e('Temp output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(flacPath);
|
||||
await tempFile.delete();
|
||||
|
||||
return flacPath;
|
||||
} else {
|
||||
_log.e('Temp output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace file after metadata embed: $e');
|
||||
return null;
|
||||
@@ -299,16 +328,16 @@ class FFmpegService {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
|
||||
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$mp3Path" ');
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v:0 copy ');
|
||||
@@ -316,9 +345,9 @@ class FFmpegService {
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
|
||||
if (metadata != null) {
|
||||
final id3Metadata = _convertToId3Tags(metadata);
|
||||
id3Metadata.forEach((key, value) {
|
||||
@@ -326,9 +355,9 @@ class FFmpegService {
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
|
||||
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg MP3 embed command: $command');
|
||||
|
||||
@@ -338,21 +367,20 @@ class FFmpegService {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
final originalFile = File(mp3Path);
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(mp3Path);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('MP3 metadata embedded successfully');
|
||||
return mp3Path;
|
||||
} else {
|
||||
_log.e('Temp MP3 output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(mp3Path);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('MP3 metadata embedded successfully');
|
||||
return mp3Path;
|
||||
} else {
|
||||
_log.e('Temp MP3 output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace MP3 file after metadata embed: $e');
|
||||
return null;
|
||||
@@ -380,26 +408,28 @@ class FFmpegService {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
|
||||
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$opusPath" ');
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
final pictureBlock = await _createMetadataBlockPicture(coverPath);
|
||||
if (pictureBlock != null) {
|
||||
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
|
||||
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
|
||||
_log.d(
|
||||
'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)',
|
||||
);
|
||||
} else {
|
||||
_log.w('Failed to create METADATA_BLOCK_PICTURE, skipping cover');
|
||||
}
|
||||
@@ -407,9 +437,9 @@ class FFmpegService {
|
||||
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg Opus embed command');
|
||||
|
||||
@@ -419,21 +449,20 @@ class FFmpegService {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
final originalFile = File(opusPath);
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(opusPath);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('Opus metadata embedded successfully');
|
||||
return opusPath;
|
||||
} else {
|
||||
_log.e('Temp Opus output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(opusPath);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('Opus metadata embedded successfully');
|
||||
return opusPath;
|
||||
} else {
|
||||
_log.e('Temp Opus output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace Opus file after metadata embed: $e');
|
||||
return null;
|
||||
@@ -460,81 +489,94 @@ class FFmpegService {
|
||||
_log.e('Cover image not found: $imagePath');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
final imageData = await file.readAsBytes();
|
||||
|
||||
|
||||
String mimeType;
|
||||
if (imagePath.toLowerCase().endsWith('.png')) {
|
||||
mimeType = 'image/png';
|
||||
} else if (imagePath.toLowerCase().endsWith('.jpg') ||
|
||||
imagePath.toLowerCase().endsWith('.jpeg')) {
|
||||
} else if (imagePath.toLowerCase().endsWith('.jpg') ||
|
||||
imagePath.toLowerCase().endsWith('.jpeg')) {
|
||||
mimeType = 'image/jpeg';
|
||||
} else {
|
||||
if (imageData.length >= 8 &&
|
||||
imageData[0] == 0x89 && imageData[1] == 0x50 &&
|
||||
imageData[2] == 0x4E && imageData[3] == 0x47) {
|
||||
if (imageData.length >= 8 &&
|
||||
imageData[0] == 0x89 &&
|
||||
imageData[1] == 0x50 &&
|
||||
imageData[2] == 0x4E &&
|
||||
imageData[3] == 0x47) {
|
||||
mimeType = 'image/png';
|
||||
} else if (imageData.length >= 2 &&
|
||||
imageData[0] == 0xFF && imageData[1] == 0xD8) {
|
||||
} else if (imageData.length >= 2 &&
|
||||
imageData[0] == 0xFF &&
|
||||
imageData[1] == 0xD8) {
|
||||
mimeType = 'image/jpeg';
|
||||
} else {
|
||||
mimeType = 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final mimeBytes = utf8.encode(mimeType);
|
||||
const description = '';
|
||||
final descBytes = utf8.encode(description);
|
||||
|
||||
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
|
||||
4 + 4 + 4 + 4 + 4 + imageData.length;
|
||||
|
||||
|
||||
final blockSize =
|
||||
4 +
|
||||
4 +
|
||||
mimeBytes.length +
|
||||
4 +
|
||||
descBytes.length +
|
||||
4 +
|
||||
4 +
|
||||
4 +
|
||||
4 +
|
||||
4 +
|
||||
imageData.length;
|
||||
|
||||
final buffer = ByteData(blockSize);
|
||||
var offset = 0;
|
||||
|
||||
|
||||
buffer.setUint32(offset, 3, Endian.big);
|
||||
offset += 4;
|
||||
|
||||
|
||||
buffer.setUint32(offset, mimeBytes.length, Endian.big);
|
||||
offset += 4;
|
||||
|
||||
|
||||
final blockBytes = Uint8List(blockSize);
|
||||
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
|
||||
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
|
||||
offset += mimeBytes.length;
|
||||
|
||||
|
||||
final tempBuffer = ByteData(4);
|
||||
tempBuffer.setUint32(0, descBytes.length, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
|
||||
offset += descBytes.length;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, 0, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
tempBuffer.setUint32(0, imageData.length, Endian.big);
|
||||
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
|
||||
offset += 4;
|
||||
|
||||
|
||||
blockBytes.setRange(offset, offset + imageData.length, imageData);
|
||||
|
||||
|
||||
final base64String = base64Encode(blockBytes);
|
||||
|
||||
|
||||
return base64String;
|
||||
} catch (e) {
|
||||
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
|
||||
@@ -542,13 +584,15 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
||||
static Map<String, String> _convertToId3Tags(
|
||||
Map<String, String> vorbisMetadata,
|
||||
) {
|
||||
final id3Map = <String, String>{};
|
||||
|
||||
|
||||
for (final entry in vorbisMetadata.entries) {
|
||||
final key = entry.key.toUpperCase();
|
||||
final value = entry.value;
|
||||
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
@@ -585,7 +629,7 @@ class FFmpegService {
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return id3Map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class HistoryDatabase {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
version: 3,
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
@@ -52,6 +52,11 @@ class HistoryDatabase {
|
||||
album_artist TEXT,
|
||||
cover_url TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
storage_mode TEXT,
|
||||
download_tree_uri TEXT,
|
||||
saf_relative_dir TEXT,
|
||||
saf_file_name TEXT,
|
||||
saf_repaired INTEGER,
|
||||
service TEXT NOT NULL,
|
||||
downloaded_at TEXT NOT NULL,
|
||||
isrc TEXT,
|
||||
@@ -80,7 +85,15 @@ class HistoryDatabase {
|
||||
|
||||
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
||||
_log.i('Upgrading database from v$oldVersion to v$newVersion');
|
||||
// Future migrations go here
|
||||
if (oldVersion < 2) {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN storage_mode TEXT');
|
||||
await db.execute('ALTER TABLE history ADD COLUMN download_tree_uri TEXT');
|
||||
await db.execute('ALTER TABLE history ADD COLUMN saf_relative_dir TEXT');
|
||||
await db.execute('ALTER TABLE history ADD COLUMN saf_file_name TEXT');
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== iOS Path Normalization ====================
|
||||
@@ -139,7 +152,6 @@ class HistoryDatabase {
|
||||
final prefs = await _prefs;
|
||||
final lastContainer = prefs.getString('ios_last_container_path');
|
||||
|
||||
// Skip if container hasn't changed
|
||||
if (lastContainer == _currentContainerPath) {
|
||||
_log.d('iOS container path unchanged, skipping migration');
|
||||
return false;
|
||||
@@ -245,6 +257,11 @@ class HistoryDatabase {
|
||||
'album_artist': json['albumArtist'],
|
||||
'cover_url': json['coverUrl'],
|
||||
'file_path': json['filePath'],
|
||||
'storage_mode': json['storageMode'],
|
||||
'download_tree_uri': json['downloadTreeUri'],
|
||||
'saf_relative_dir': json['safRelativeDir'],
|
||||
'saf_file_name': json['safFileName'],
|
||||
'saf_repaired': json['safRepaired'] == true ? 1 : 0,
|
||||
'service': json['service'],
|
||||
'downloaded_at': json['downloadedAt'],
|
||||
'isrc': json['isrc'],
|
||||
@@ -273,6 +290,11 @@ class HistoryDatabase {
|
||||
'albumArtist': row['album_artist'],
|
||||
'coverUrl': row['cover_url'],
|
||||
'filePath': _normalizeIosPath(row['file_path'] as String?),
|
||||
'storageMode': row['storage_mode'],
|
||||
'downloadTreeUri': row['download_tree_uri'],
|
||||
'safRelativeDir': row['saf_relative_dir'],
|
||||
'safFileName': row['saf_file_name'],
|
||||
'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true,
|
||||
'service': row['service'],
|
||||
'downloadedAt': row['downloaded_at'],
|
||||
'isrc': row['isrc'],
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
|
||||
final _log = AppLogger('LibraryDatabase');
|
||||
|
||||
class LocalLibraryItem {
|
||||
final String id;
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String? albumArtist;
|
||||
final String filePath;
|
||||
final String? coverPath;
|
||||
final DateTime scannedAt;
|
||||
final String? isrc;
|
||||
final int? trackNumber;
|
||||
final int? discNumber;
|
||||
final int? duration;
|
||||
final String? releaseDate;
|
||||
final int? bitDepth;
|
||||
final int? sampleRate;
|
||||
final String? genre;
|
||||
final String? format; // flac, mp3, opus, m4a
|
||||
|
||||
const LocalLibraryItem({
|
||||
required this.id,
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
this.albumArtist,
|
||||
required this.filePath,
|
||||
this.coverPath,
|
||||
required this.scannedAt,
|
||||
this.isrc,
|
||||
this.trackNumber,
|
||||
this.discNumber,
|
||||
this.duration,
|
||||
this.releaseDate,
|
||||
this.bitDepth,
|
||||
this.sampleRate,
|
||||
this.genre,
|
||||
this.format,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'trackName': trackName,
|
||||
'artistName': artistName,
|
||||
'albumName': albumName,
|
||||
'albumArtist': albumArtist,
|
||||
'filePath': filePath,
|
||||
'coverPath': coverPath,
|
||||
'scannedAt': scannedAt.toIso8601String(),
|
||||
'isrc': isrc,
|
||||
'trackNumber': trackNumber,
|
||||
'discNumber': discNumber,
|
||||
'duration': duration,
|
||||
'releaseDate': releaseDate,
|
||||
'bitDepth': bitDepth,
|
||||
'sampleRate': sampleRate,
|
||||
'genre': genre,
|
||||
'format': format,
|
||||
};
|
||||
|
||||
factory LocalLibraryItem.fromJson(Map<String, dynamic> json) =>
|
||||
LocalLibraryItem(
|
||||
id: json['id'] as String,
|
||||
trackName: json['trackName'] as String,
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
filePath: json['filePath'] as String,
|
||||
coverPath: json['coverPath'] as String?,
|
||||
scannedAt: DateTime.parse(json['scannedAt'] as String),
|
||||
isrc: json['isrc'] as String?,
|
||||
trackNumber: json['trackNumber'] as int?,
|
||||
discNumber: json['discNumber'] as int?,
|
||||
duration: json['duration'] as int?,
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
bitDepth: json['bitDepth'] as int?,
|
||||
sampleRate: json['sampleRate'] as int?,
|
||||
genre: json['genre'] as String?,
|
||||
format: json['format'] as String?,
|
||||
);
|
||||
|
||||
/// Create a unique key for matching tracks
|
||||
String get matchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
String get albumKey => '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}';
|
||||
}
|
||||
|
||||
class LibraryDatabase {
|
||||
static final LibraryDatabase instance = LibraryDatabase._init();
|
||||
static Database? _database;
|
||||
|
||||
LibraryDatabase._init();
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDB('local_library.db');
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _initDB(String fileName) async {
|
||||
final dbPath = await getApplicationDocumentsDirectory();
|
||||
final path = join(dbPath.path, fileName);
|
||||
|
||||
_log.i('Initializing library database at: $path');
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 2, // Bumped version for cover_path migration
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createDB(Database db, int version) async {
|
||||
_log.i('Creating library database schema v$version');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE library (
|
||||
id TEXT PRIMARY KEY,
|
||||
track_name TEXT NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL,
|
||||
album_artist TEXT,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
scanned_at TEXT NOT NULL,
|
||||
isrc TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
duration INTEGER,
|
||||
release_date TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
genre TEXT,
|
||||
format TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)');
|
||||
await db.execute('CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)');
|
||||
await db.execute('CREATE INDEX idx_library_album ON library(album_name, album_artist)');
|
||||
await db.execute('CREATE INDEX idx_library_file_path ON library(file_path)');
|
||||
|
||||
_log.i('Library database schema created with indexes');
|
||||
}
|
||||
|
||||
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
||||
_log.i('Upgrading library database from v$oldVersion to v$newVersion');
|
||||
|
||||
if (oldVersion < 2) {
|
||||
// Add cover_path column
|
||||
await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT');
|
||||
_log.i('Added cover_path column');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
return {
|
||||
'id': json['id'],
|
||||
'track_name': json['trackName'],
|
||||
'artist_name': json['artistName'],
|
||||
'album_name': json['albumName'],
|
||||
'album_artist': json['albumArtist'],
|
||||
'file_path': json['filePath'],
|
||||
'cover_path': json['coverPath'],
|
||||
'scanned_at': json['scannedAt'],
|
||||
'isrc': json['isrc'],
|
||||
'track_number': json['trackNumber'],
|
||||
'disc_number': json['discNumber'],
|
||||
'duration': json['duration'],
|
||||
'release_date': json['releaseDate'],
|
||||
'bit_depth': json['bitDepth'],
|
||||
'sample_rate': json['sampleRate'],
|
||||
'genre': json['genre'],
|
||||
'format': json['format'],
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||
return {
|
||||
'id': row['id'],
|
||||
'trackName': row['track_name'],
|
||||
'artistName': row['artist_name'],
|
||||
'albumName': row['album_name'],
|
||||
'albumArtist': row['album_artist'],
|
||||
'filePath': row['file_path'],
|
||||
'coverPath': row['cover_path'],
|
||||
'scannedAt': row['scanned_at'],
|
||||
'isrc': row['isrc'],
|
||||
'trackNumber': row['track_number'],
|
||||
'discNumber': row['disc_number'],
|
||||
'duration': row['duration'],
|
||||
'releaseDate': row['release_date'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'genre': row['genre'],
|
||||
'format': row['format'],
|
||||
};
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
'library',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'library',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
_log.i('Batch inserted ${items.length} items');
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
orderBy: 'album_artist, album_name, disc_number, track_number',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
where: 'isrc = ?',
|
||||
whereArgs: [isrc],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
Future<bool> existsByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery(
|
||||
'SELECT 1 FROM library WHERE isrc = ? LIMIT 1',
|
||||
[isrc],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> findByTrackAndArtist(
|
||||
String trackName,
|
||||
String artistName,
|
||||
) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
where: 'LOWER(track_name) = ? AND LOWER(artist_name) = ?',
|
||||
whereArgs: [trackName.toLowerCase(), artistName.toLowerCase()],
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> findExisting({
|
||||
String? isrc,
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
}) async {
|
||||
// First try ISRC if available
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = await getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
|
||||
// Then try name matching
|
||||
if (trackName != null && artistName != null) {
|
||||
final matches = await findByTrackAndArtist(trackName, artistName);
|
||||
if (matches.isNotEmpty) return matches.first;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Set<String>> getAllIsrcs() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""'
|
||||
);
|
||||
return rows.map((r) => r['isrc'] as String).toSet();
|
||||
}
|
||||
|
||||
Future<Set<String>> getAllTrackKeys() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library'
|
||||
);
|
||||
return rows.map((r) => r['match_key'] as String).toSet();
|
||||
}
|
||||
|
||||
Future<void> deleteByPath(String filePath) async {
|
||||
final db = await database;
|
||||
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
final db = await database;
|
||||
await db.delete('library', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final db = await database;
|
||||
final rows = await db.query('library', columns: ['id', 'file_path']);
|
||||
|
||||
int removed = 0;
|
||||
for (final row in rows) {
|
||||
final filePath = row['file_path'] as String;
|
||||
if (!await fileExists(filePath)) {
|
||||
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
_log.i('Cleaned up $removed missing files from library');
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
final db = await database;
|
||||
await db.delete('library');
|
||||
_log.i('Cleared all library data');
|
||||
}
|
||||
|
||||
Future<int> getCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM library');
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> search(String query, {int limit = 50}) async {
|
||||
final db = await database;
|
||||
final searchQuery = '%${query.toLowerCase()}%';
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
where: 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?',
|
||||
whereArgs: [searchQuery, searchQuery, searchQuery],
|
||||
orderBy: 'track_name',
|
||||
limit: limit,
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
@@ -145,12 +145,20 @@ class NotificationService {
|
||||
required String artistName,
|
||||
int? completedCount,
|
||||
int? totalCount,
|
||||
bool alreadyInLibrary = false,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
final title = completedCount != null && totalCount != null
|
||||
? 'Download Complete ($completedCount/$totalCount)'
|
||||
: 'Download Complete';
|
||||
String title;
|
||||
if (alreadyInLibrary) {
|
||||
title = completedCount != null && totalCount != null
|
||||
? 'Already in Library ($completedCount/$totalCount)'
|
||||
: 'Already in Library';
|
||||
} else {
|
||||
title = completedCount != null && totalCount != null
|
||||
? 'Download Complete ($completedCount/$totalCount)'
|
||||
: 'Download Complete';
|
||||
}
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
channelId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
@@ -8,7 +9,6 @@ class PaletteService {
|
||||
static final PaletteService instance = PaletteService._();
|
||||
PaletteService._();
|
||||
|
||||
/// Cache for already computed colors
|
||||
final Map<String, Color> _colorCache = {};
|
||||
|
||||
/// Extract dominant color from a network image URL
|
||||
@@ -46,12 +46,43 @@ class PaletteService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the color cache
|
||||
Future<Color?> extractDominantColorFromFile(String? filePath) async {
|
||||
if (filePath == null || filePath.isEmpty) return null;
|
||||
|
||||
final cached = _colorCache[filePath];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) return null;
|
||||
|
||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||
FileImage(file),
|
||||
size: const Size(64, 64),
|
||||
maximumColorCount: 8,
|
||||
);
|
||||
|
||||
final color = paletteGenerator.dominantColor?.color ??
|
||||
paletteGenerator.vibrantColor?.color ??
|
||||
paletteGenerator.mutedColor?.color;
|
||||
|
||||
if (color != null) {
|
||||
_colorCache[filePath] = color;
|
||||
}
|
||||
|
||||
return color;
|
||||
} catch (e) {
|
||||
debugPrint('PaletteService file error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void clearCache() {
|
||||
_colorCache.clear();
|
||||
}
|
||||
|
||||
/// Get cached color without computing
|
||||
Color? getCached(String? imageUrl) {
|
||||
if (imageUrl == null) return null;
|
||||
return _colorCache[imageUrl];
|
||||
|
||||
@@ -67,6 +67,11 @@ class PlatformBridge {
|
||||
String? releaseDate,
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
String storageMode = 'app',
|
||||
String safTreeUri = '',
|
||||
String safRelativeDir = '',
|
||||
String safFileName = '',
|
||||
String safOutputExt = '',
|
||||
}) async {
|
||||
_log.i('downloadTrack: "$trackName" by $artistName via $service');
|
||||
final request = jsonEncode({
|
||||
@@ -89,6 +94,11 @@ class PlatformBridge {
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
'duration_ms': durationMs,
|
||||
'storage_mode': storageMode,
|
||||
'saf_tree_uri': safTreeUri,
|
||||
'saf_relative_dir': safRelativeDir,
|
||||
'saf_file_name': safFileName,
|
||||
'saf_output_ext': safOutputExt,
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
||||
@@ -125,6 +135,11 @@ class PlatformBridge {
|
||||
String? label,
|
||||
String? copyright,
|
||||
String lyricsMode = 'embed',
|
||||
String storageMode = 'app',
|
||||
String safTreeUri = '',
|
||||
String safRelativeDir = '',
|
||||
String safFileName = '',
|
||||
String safOutputExt = '',
|
||||
}) async {
|
||||
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
|
||||
final request = jsonEncode({
|
||||
@@ -151,6 +166,11 @@ class PlatformBridge {
|
||||
'label': label ?? '',
|
||||
'copyright': copyright ?? '',
|
||||
'lyrics_mode': lyricsMode,
|
||||
'storage_mode': storageMode,
|
||||
'saf_tree_uri': safTreeUri,
|
||||
'saf_relative_dir': safRelativeDir,
|
||||
'saf_file_name': safFileName,
|
||||
'saf_output_ext': safOutputExt,
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||
@@ -225,6 +245,80 @@ class PlatformBridge {
|
||||
return result as String;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> pickSafTree() async {
|
||||
final result = await _channel.invokeMethod('pickSafTree');
|
||||
if (result == null) return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<bool> safExists(String uri) async {
|
||||
final result = await _channel.invokeMethod('safExists', {'uri': uri});
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
static Future<bool> safDelete(String uri) async {
|
||||
final result = await _channel.invokeMethod('safDelete', {'uri': uri});
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> safStat(String uri) async {
|
||||
final result = await _channel.invokeMethod('safStat', {'uri': uri});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> resolveSafFile({
|
||||
required String treeUri,
|
||||
required String fileName,
|
||||
String relativeDir = '',
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('resolveSafFile', {
|
||||
'tree_uri': treeUri,
|
||||
'relative_dir': relativeDir,
|
||||
'file_name': fileName,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<String?> copyContentUriToTemp(String uri) async {
|
||||
final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri});
|
||||
return result as String?;
|
||||
}
|
||||
|
||||
static Future<bool> replaceContentUriFromPath(
|
||||
String uri,
|
||||
String srcPath,
|
||||
) async {
|
||||
final result = await _channel.invokeMethod('safReplaceFromPath', {
|
||||
'uri': uri,
|
||||
'src_path': srcPath,
|
||||
});
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
static Future<String?> createSafFileFromPath({
|
||||
required String treeUri,
|
||||
required String relativeDir,
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
required String srcPath,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('safCreateFromPath', {
|
||||
'tree_uri': treeUri,
|
||||
'relative_dir': relativeDir,
|
||||
'file_name': fileName,
|
||||
'mime_type': mimeType,
|
||||
'src_path': srcPath,
|
||||
});
|
||||
return result as String?;
|
||||
}
|
||||
|
||||
static Future<void> openContentUri(String uri, {String mimeType = ''}) async {
|
||||
await _channel.invokeMethod('openContentUri', {
|
||||
'uri': uri,
|
||||
'mime_type': mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> fetchLyrics(
|
||||
String spotifyId,
|
||||
String trackName,
|
||||
@@ -368,6 +462,16 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> parseTidalUrl(String url) async {
|
||||
final result = await _channel.invokeMethod('parseTidalUrl', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(String tidalUrl) async {
|
||||
final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', {'url': tidalUrl});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
|
||||
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
@@ -583,6 +687,11 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
String? label,
|
||||
String lyricsMode = 'embed',
|
||||
String? preferredService,
|
||||
String storageMode = 'app',
|
||||
String safTreeUri = '',
|
||||
String safRelativeDir = '',
|
||||
String safFileName = '',
|
||||
String safOutputExt = '',
|
||||
}) async {
|
||||
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}');
|
||||
final request = jsonEncode({
|
||||
@@ -609,6 +718,11 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
'label': label ?? '',
|
||||
'lyrics_mode': lyricsMode,
|
||||
'service': preferredService ?? '',
|
||||
'storage_mode': storageMode,
|
||||
'saf_tree_uri': safTreeUri,
|
||||
'saf_relative_dir': safRelativeDir,
|
||||
'saf_file_name': safFileName,
|
||||
'saf_output_ext': safOutputExt,
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadWithExtensions', request);
|
||||
@@ -821,6 +935,61 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== LOCAL LIBRARY SCANNING ====================
|
||||
|
||||
/// Set the directory for caching extracted cover art
|
||||
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
|
||||
_log.i('setLibraryCoverCacheDir: $cacheDir');
|
||||
await _channel.invokeMethod('setLibraryCoverCacheDir', {
|
||||
'cache_dir': cacheDir,
|
||||
});
|
||||
}
|
||||
|
||||
/// Scan a folder for audio files and read their metadata
|
||||
/// Returns a list of track metadata
|
||||
static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async {
|
||||
_log.i('scanLibraryFolder: $folderPath');
|
||||
final result = await _channel.invokeMethod('scanLibraryFolder', {
|
||||
'folder_path': folderPath,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
|
||||
_log.i('scanSafTree: $treeUri');
|
||||
final result = await _channel.invokeMethod('scanSafTree', {
|
||||
'tree_uri': treeUri,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get current library scan progress
|
||||
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
|
||||
final result = await _channel.invokeMethod('getLibraryScanProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Cancel ongoing library scan
|
||||
static Future<void> cancelLibraryScan() async {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
}
|
||||
|
||||
/// Read metadata from a single audio file
|
||||
static Future<Map<String, dynamic>?> readAudioMetadata(String filePath) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('readAudioMetadata', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.w('Failed to read audio metadata: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static Future<Map<String, dynamic>> runPostProcessing(
|
||||
String filePath, {
|
||||
@@ -833,6 +1002,23 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> runPostProcessingV2(
|
||||
String filePath, {
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
final input = <String, dynamic>{};
|
||||
if (filePath.startsWith('content://')) {
|
||||
input['uri'] = filePath;
|
||||
} else {
|
||||
input['path'] = filePath;
|
||||
}
|
||||
final result = await _channel.invokeMethod('runPostProcessingV2', {
|
||||
'input': jsonEncode(input),
|
||||
'metadata': metadata != null ? jsonEncode(metadata) : '',
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
|
||||
final result = await _channel.invokeMethod('getPostProcessingProviders');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
|
||||
@@ -9,16 +9,35 @@ class ShareIntentService {
|
||||
factory ShareIntentService() => _instance;
|
||||
ShareIntentService._internal();
|
||||
|
||||
// Spotify patterns
|
||||
static final RegExp _spotifyUriPattern =
|
||||
RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+');
|
||||
static final RegExp _spotifyUrlPattern = RegExp(
|
||||
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
||||
);
|
||||
|
||||
// Deezer patterns
|
||||
static final RegExp _deezerUrlPattern = RegExp(
|
||||
r'https?://(www\.)?deezer\.com/(track|album|playlist|artist)/\d+(\?[^\s]*)?',
|
||||
);
|
||||
static final RegExp _deezerShortLinkPattern = RegExp(
|
||||
r'https?://deezer\.page\.link/[a-zA-Z0-9]+',
|
||||
);
|
||||
|
||||
// Tidal patterns
|
||||
static final RegExp _tidalUrlPattern = RegExp(
|
||||
r'https?://(listen\.)?tidal\.com/(track|album|playlist|artist)/[a-zA-Z0-9-]+(\?[^\s]*)?',
|
||||
);
|
||||
|
||||
// YouTube Music patterns
|
||||
static final RegExp _ytMusicUrlPattern = RegExp(
|
||||
r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/)[a-zA-Z0-9_-]+(\&[^\s]*)?',
|
||||
);
|
||||
|
||||
final _sharedUrlController = StreamController<String>.broadcast();
|
||||
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
|
||||
bool _initialized = false;
|
||||
String? _pendingUrl; // Store URL received before listener is ready
|
||||
String? _pendingUrl;
|
||||
|
||||
Stream<String> get sharedUrlStream => _sharedUrlController.stream;
|
||||
|
||||
@@ -46,33 +65,55 @@ class ShareIntentService {
|
||||
|
||||
void _handleSharedMedia(List<SharedMediaFile> files, {bool isInitial = false}) {
|
||||
for (final file in files) {
|
||||
final textToCheck = file.path;
|
||||
// Check both path and message - apps may share URL in either field
|
||||
final textsToCheck = [
|
||||
file.path,
|
||||
if (file.message != null) file.message!,
|
||||
];
|
||||
|
||||
final url = _extractSpotifyUrl(textToCheck);
|
||||
if (url != null) {
|
||||
_log.i('Received Spotify URL: $url (initial: $isInitial)');
|
||||
if (isInitial) {
|
||||
_pendingUrl = url;
|
||||
for (final textToCheck in textsToCheck) {
|
||||
final url = _extractMusicUrl(textToCheck);
|
||||
if (url != null) {
|
||||
_log.i('Received music URL: $url (initial: $isInitial)');
|
||||
if (isInitial) {
|
||||
_pendingUrl = url;
|
||||
}
|
||||
_sharedUrlController.add(url);
|
||||
return;
|
||||
}
|
||||
_sharedUrlController.add(url);
|
||||
return; // Only process first valid URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _extractSpotifyUrl(String text) {
|
||||
String? _extractMusicUrl(String text) {
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
// Try Spotify URI first
|
||||
final uriMatch = _spotifyUriPattern.firstMatch(text);
|
||||
if (uriMatch != null) {
|
||||
return uriMatch.group(0);
|
||||
}
|
||||
|
||||
final urlMatch = _spotifyUrlPattern.firstMatch(text);
|
||||
if (urlMatch != null) {
|
||||
final fullUrl = urlMatch.group(0)!;
|
||||
final queryIndex = fullUrl.indexOf('?');
|
||||
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
|
||||
// Try all URL patterns
|
||||
final patterns = [
|
||||
_spotifyUrlPattern,
|
||||
_deezerUrlPattern,
|
||||
_deezerShortLinkPattern,
|
||||
_tidalUrlPattern,
|
||||
_ytMusicUrlPattern,
|
||||
];
|
||||
|
||||
for (final pattern in patterns) {
|
||||
final match = pattern.firstMatch(text);
|
||||
if (match != null) {
|
||||
final fullUrl = match.group(0)!;
|
||||
// Remove query params for cleaner URL (except for YT Music which needs them)
|
||||
if (pattern == _ytMusicUrlPattern) {
|
||||
return fullUrl;
|
||||
}
|
||||
final queryIndex = fullUrl.indexOf('?');
|
||||
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
|
||||
class FileAccessStat {
|
||||
final int? size;
|
||||
final DateTime? modified;
|
||||
|
||||
const FileAccessStat({this.size, this.modified});
|
||||
}
|
||||
|
||||
bool isContentUri(String? path) {
|
||||
return path != null && path.startsWith('content://');
|
||||
}
|
||||
|
||||
Future<bool> fileExists(String? path) async {
|
||||
if (path == null || path.isEmpty) return false;
|
||||
if (isContentUri(path)) {
|
||||
return PlatformBridge.safExists(path);
|
||||
}
|
||||
return File(path).exists();
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String? path) async {
|
||||
if (path == null || path.isEmpty) return;
|
||||
if (isContentUri(path)) {
|
||||
await PlatformBridge.safDelete(path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(path).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<FileAccessStat?> fileStat(String? path) async {
|
||||
if (path == null || path.isEmpty) return null;
|
||||
if (isContentUri(path)) {
|
||||
final stat = await PlatformBridge.safStat(path);
|
||||
final exists = stat['exists'] as bool? ?? true;
|
||||
if (!exists) return null;
|
||||
return FileAccessStat(
|
||||
size: stat['size'] as int?,
|
||||
modified: stat['modified'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(stat['modified'] as int)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
final stat = await FileStat.stat(path);
|
||||
if (stat.type == FileSystemEntityType.notFound) return null;
|
||||
return FileAccessStat(size: stat.size, modified: stat.modified);
|
||||
}
|
||||
|
||||
Future<void> openFile(String path) async {
|
||||
if (isContentUri(path)) {
|
||||
await PlatformBridge.openContentUri(path, mimeType: '');
|
||||
return;
|
||||
}
|
||||
final mimeType = audioMimeTypeForPath(path);
|
||||
final result = await OpenFilex.open(path, type: mimeType);
|
||||
if (result.type != ResultType.done) {
|
||||
throw Exception(result.message);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
class LogEntry {
|
||||
@@ -151,6 +154,96 @@ class LogBuffer extends ChangeNotifier {
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> exportWithDeviceInfo() async {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln('SPOTIFLAC LOG EXPORT');
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln();
|
||||
|
||||
buffer.writeln('--- App Information ---');
|
||||
buffer.writeln('App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})');
|
||||
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln();
|
||||
|
||||
buffer.writeln('--- Device Information ---');
|
||||
try {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final android = await deviceInfo.androidInfo;
|
||||
buffer.writeln('Platform: Android');
|
||||
buffer.writeln('Device: ${android.manufacturer} ${android.model}');
|
||||
buffer.writeln('Brand: ${android.brand}');
|
||||
buffer.writeln('Android Version: ${android.version.release} (SDK ${android.version.sdkInt})');
|
||||
buffer.writeln('Device ID: ${android.id}');
|
||||
buffer.writeln('Hardware: ${android.hardware}');
|
||||
buffer.writeln('Product: ${android.product}');
|
||||
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
|
||||
buffer.writeln('Is Physical Device: ${android.isPhysicalDevice}');
|
||||
} else if (Platform.isIOS) {
|
||||
final ios = await deviceInfo.iosInfo;
|
||||
buffer.writeln('Platform: iOS');
|
||||
buffer.writeln('Device: ${ios.utsname.machine}');
|
||||
buffer.writeln('Model: ${ios.model}');
|
||||
buffer.writeln('System Name: ${ios.systemName}');
|
||||
buffer.writeln('System Version: ${ios.systemVersion}');
|
||||
buffer.writeln('Device Name: ${ios.name}');
|
||||
buffer.writeln('Is Physical Device: ${ios.isPhysicalDevice}');
|
||||
}
|
||||
} catch (e) {
|
||||
buffer.writeln('Failed to get device info: $e');
|
||||
}
|
||||
buffer.writeln();
|
||||
|
||||
buffer.writeln('--- Log Summary ---');
|
||||
buffer.writeln('Total Entries: ${_entries.length}');
|
||||
|
||||
int errorCount = 0;
|
||||
int warnCount = 0;
|
||||
int infoCount = 0;
|
||||
int debugCount = 0;
|
||||
int goCount = 0;
|
||||
|
||||
for (final entry in _entries) {
|
||||
switch (entry.level) {
|
||||
case 'ERROR':
|
||||
case 'FATAL':
|
||||
errorCount++;
|
||||
break;
|
||||
case 'WARN':
|
||||
warnCount++;
|
||||
break;
|
||||
case 'INFO':
|
||||
infoCount++;
|
||||
break;
|
||||
case 'DEBUG':
|
||||
debugCount++;
|
||||
break;
|
||||
}
|
||||
if (entry.isFromGo) goCount++;
|
||||
}
|
||||
|
||||
buffer.writeln('Errors: $errorCount');
|
||||
buffer.writeln('Warnings: $warnCount');
|
||||
buffer.writeln('Info: $infoCount');
|
||||
buffer.writeln('Debug: $debugCount');
|
||||
buffer.writeln('From Go Backend: $goCount');
|
||||
buffer.writeln();
|
||||
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln('LOG ENTRIES');
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln();
|
||||
|
||||
for (final entry in _entries) {
|
||||
buffer.writeln(entry.toString());
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
List<LogEntry> filter({String? level, String? tag, String? search}) {
|
||||
final tagLower = tag?.toLowerCase();
|
||||
final searchLower = search?.toLowerCase();
|
||||
|
||||
@@ -334,6 +334,28 @@ Padding(
|
||||
widget.onSelect('HIGH', _selectedService);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: const Text('Opus 256kbps'),
|
||||
subtitle: const Text('Best quality Opus, ~8MB per track'),
|
||||
trailing: currentFormat == 'opus_256'
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256');
|
||||
Navigator.pop(modalContext); // Close format picker
|
||||
Navigator.pop(context); // Close service picker
|
||||
widget.onSelect('HIGH', _selectedService);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Container(
|
||||
@@ -345,7 +367,7 @@ Padding(
|
||||
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
|
||||
),
|
||||
title: const Text('Opus 128kbps'),
|
||||
subtitle: const Text('Modern codec, ~4MB per track'),
|
||||
subtitle: const Text('Smallest size, ~4MB per track'),
|
||||
trailing: currentFormat == 'opus_128'
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: null,
|
||||
|
||||
@@ -43,8 +43,8 @@ class SettingsGroup extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single settings item that can be used inside SettingsGroup
|
||||
class SettingsItem extends StatelessWidget {
|
||||
|
||||
final IconData? icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
@@ -125,8 +125,8 @@ class SettingsItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A switch settings item for SettingsGroup
|
||||
class SettingsSwitchItem extends StatelessWidget {
|
||||
|
||||
final IconData? icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
@@ -213,8 +213,8 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Section header for settings groups
|
||||
class SettingsSectionHeader extends StatelessWidget {
|
||||
|
||||
final String title;
|
||||
|
||||
const SettingsSectionHeader({super.key, required this.title});
|
||||
|
||||
+74
-2
@@ -185,6 +185,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
connectivity_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_platform_interface
|
||||
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -419,6 +435,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -529,10 +593,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -637,6 +701,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.6.1"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.3.5+70
|
||||
version: 3.5.0-beta.1+73
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -24,13 +24,15 @@ dependencies:
|
||||
|
||||
# Storage & Persistence
|
||||
shared_preferences: ^2.5.3
|
||||
flutter_secure_storage: ^9.2.2
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.0
|
||||
sqflite: ^2.4.1
|
||||
|
||||
# HTTP & Network
|
||||
# HTTP & Network
|
||||
http: ^1.6.0
|
||||
dio: ^5.8.0
|
||||
connectivity_plus: ^6.0.3
|
||||
|
||||
# UI Components
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
Reference in New Issue
Block a user