Compare commits

...

61 Commits

Author SHA1 Message Date
zarzet 61720f3f2a chore(ios): sync FFmpeg service and add palette_generator dependency 2026-01-19 02:55:39 +07:00
zarzet 7749399239 docs: add translator credits to changelog 2026-01-19 02:41:57 +07:00
zarzet d143b82068 fix: add es_ES and pt_PT locale codes to language selector 2026-01-19 02:33:12 +07:00
zarzet 606e7c1079 fix: change translator links from GitHub to Crowdin profiles 2026-01-19 02:28:35 +07:00
zarzet a650632c4e feat: add translators section in about page and fix ARB locale format 2026-01-19 02:25:30 +07:00
zarzet 3c118f74e4 chore: rename ARB files and add Spanish/Portuguese languages 2026-01-19 02:17:32 +07:00
zarzet bc3055f6e1 chore: update supported locales 2026-01-19 02:14:54 +07:00
zarzet 7c86ae0b7e feat: add quick search provider switcher and genre/label for extensions
- Add dropdown menu in search bar for instant provider switching
- Support genre & label metadata for extension downloads
- Bump version to 3.1.2 (build 61)
2026-01-19 02:14:52 +07:00
zarzet 595bfb2711 feat: add button setting type for extension actions
- Add SettingTypeButton for action buttons in extension settings
- Add Action field to ExtensionSetting for JS function name
- Update extension detail page UI to render button settings
- Add InvokeAction method to execute button actions
2026-01-19 02:14:52 +07:00
zarzet 5f39a3d52f fix: use CollapseMode.none for smoother header animation 2026-01-19 02:14:50 +07:00
zarzet e7077781e6 feat: add genre and label metadata to FLAC downloads
- Fetch genre and label from Deezer album API before download
- Add GENRE, ORGANIZATION (label), and COPYRIGHT tags to FLAC files
- Update Go Metadata struct with new fields
- Add GetDeezerExtendedMetadata export function for Flutter
- Register platform channel handlers for Android and iOS
- Pass genre/label through download flow to all services (Tidal/Qobuz/Amazon)
2026-01-19 02:14:50 +07:00
zarzet 42d15db4ca fix: show 'Artist' label for artist items instead of 'Album'
Fixed fallback subtitle in _CollectionItemWidget for artist search results
2026-01-19 02:14:49 +07:00
zarzet c2599981d6 fix: Clear All now hides ALL downloads, not just visible 10
Previously only hid uniqueItems (max 10 visible), now hides all downloadItems
2026-01-19 02:14:48 +07:00
zarzet a1647a41ff fix: use ref.watch for hiddenDownloadIds reactivity
Show All Downloads button now updates immediately without restart
2026-01-19 02:14:47 +07:00
zarzet bf2fc7702b chore: remove debug print statements from recent_access_provider 2026-01-19 02:14:46 +07:00
zarzet f814408702 style: reduce AppBar title font size to 16px for long titles 2026-01-19 02:14:45 +07:00
zarzet 6b1958bfd0 feat: show 'Show All Downloads' button when recents is empty
- Button appears when all items are cleared/hidden
- Clicking resets hidden downloads list
- Clear All button only shows when there are items
- Empty state with visibility_off icon
2026-01-19 02:14:29 +07:00
zarzet bc120ffa76 feat: allow hiding downloads from recents without deleting files
- Add hiddenDownloadIds set to RecentAccessState
- X button on download items hides from recents (not delete file)
- Hidden IDs persisted in SharedPreferences
- Clear All also clears hidden downloads list
- Single track shows as Track, 2+ tracks shows as Album in recents
2026-01-19 02:14:27 +07:00
zarzet 5ea454a0b0 fix: downloaded album navigation from recents 2026-01-19 02:14:26 +07:00
zarzet da574f895c feat: v3.1.2 - MP3 option, dominant color headers, sticky titles, disc separation
Added:
- MP3 quality option with FLAC-to-MP3 conversion (320kbps)
- Dominant color header backgrounds on detail screens
- Spotify-style sticky title on scroll (album, playlist, artist screens)
- Disc separation for multi-disc albums
- Album grouping in recent downloads
- 50% screen width cover art

Changed:
- Improved FFmpeg FLAC-to-MP3 conversion workflow
- AppBar uses theme surface color when collapsed

Fixed:
- Empty catch blocks with proper comments
- Russian plural forms (ICU syntax)

Dependencies:
- Added palette_generator ^0.3.3+4
2026-01-19 02:13:53 +07:00
Zarz Eleutherius 1c445e91d9 Merge pull request #77 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 02:12:44 +07:00
Zarz Eleutherius 5d03eb0656 New translations app_en.arb (Portuguese) 2026-01-19 02:11:51 +07:00
Zarz Eleutherius becb6845a6 Merge pull request #68 from zarzet/l10n_dev
New Crowdin updates
2026-01-19 00:48:32 +07:00
Zarz Eleutherius be3ee3b216 New translations app_en.arb (Chinese Traditional) 2026-01-19 00:29:39 +07:00
Zarz Eleutherius 3747674968 New translations app_en.arb (Russian) 2026-01-19 00:29:37 +07:00
Zarz Eleutherius ff9d088c5f New translations app_en.arb (German) 2026-01-19 00:29:34 +07:00
Zarz Eleutherius 12db11d559 New translations app_en.arb (Spanish) 2026-01-19 00:29:33 +07:00
Zarz Eleutherius 7e1aca33a5 New translations app_en.arb (Hindi) 2026-01-18 03:42:29 +07:00
Zarz Eleutherius 07a1c68354 New translations app_en.arb (Indonesian) 2026-01-18 03:42:28 +07:00
Zarz Eleutherius f4d7c6531f New translations app_en.arb (Chinese Traditional) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius e9ca054682 New translations app_en.arb (Chinese Simplified) 2026-01-18 03:42:27 +07:00
Zarz Eleutherius 1069bdd0d8 New translations app_en.arb (Portuguese) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius ff882a58d7 New translations app_en.arb (Dutch) 2026-01-18 03:42:25 +07:00
Zarz Eleutherius dddc8c3d94 New translations app_en.arb (Korean) 2026-01-18 03:42:24 +07:00
Zarz Eleutherius 720525b67b New translations app_en.arb (German) 2026-01-18 03:42:22 +07:00
Zarz Eleutherius cc12f63d36 New translations app_en.arb (Spanish) 2026-01-18 03:42:21 +07:00
Zarz Eleutherius 5c67553596 New translations app_en.arb (French) 2026-01-18 03:42:20 +07:00
zarzet 0ccda8db58 fix: locale format and translation updates 2026-01-18 03:27:43 +07:00
zarzet 6d7b89b881 v3.1.1: Lyrics caching, duration matching, Deezer cover upgrade, live extension search, Russian language, fix race conditions and scroll exceptions 2026-01-18 03:15:20 +07:00
Zarz Eleutherius 47777b4343 Merge pull request #65 from zarzet/l10n_dev
New Crowdin updates
2026-01-18 01:46:48 +07:00
Zarz Eleutherius 2eb1d2a65d New translations app_en.arb (Russian) 2026-01-18 01:45:39 +07:00
Zarz Eleutherius ce057c6473 New translations app_en.arb (Japanese) 2026-01-18 01:45:36 +07:00
Zarz Eleutherius 46cfe8b632 Merge pull request #58 from zarzet/l10n_dev
New Crowdin updates
2026-01-17 22:01:48 +07:00
zarzet 2e5eff6e3d chore: add cursor files to gitignore 2026-01-17 10:10:56 +07:00
zarzet dd506efeb6 chore: remove .cursorignore from tracking 2026-01-17 10:10:20 +07:00
zarzet 8d92d22fda refactor: more code cleanup 2026-01-17 10:04:21 +07:00
zarzet b99764b1ad refactor: cleanup unused code and imports 2026-01-17 09:50:00 +07:00
zarzet 621582cf11 refactor: additional code cleanup 2026-01-17 09:36:05 +07:00
zarzet b96233f90b refactor: code cleanup and improvements 2026-01-17 09:07:29 +07:00
Zarz Eleutherius 65e21a421d New translations app_en.arb (Hindi) 2026-01-17 05:23:55 +07:00
Zarz Eleutherius 87b33dda7e New translations app_en.arb (Indonesian) 2026-01-17 05:23:54 +07:00
Zarz Eleutherius 2f097c8f6c New translations app_en.arb (Chinese Traditional) 2026-01-17 05:23:53 +07:00
Zarz Eleutherius 8cbdea1417 New translations app_en.arb (Chinese Simplified) 2026-01-17 05:23:52 +07:00
Zarz Eleutherius 48bdd154f6 New translations app_en.arb (Russian) 2026-01-17 05:23:51 +07:00
Zarz Eleutherius ae0e157c34 New translations app_en.arb (Portuguese) 2026-01-17 05:23:50 +07:00
Zarz Eleutherius 53fcdd9a47 New translations app_en.arb (Dutch) 2026-01-17 05:23:49 +07:00
Zarz Eleutherius 3d6be3bf92 New translations app_en.arb (Korean) 2026-01-17 05:23:48 +07:00
Zarz Eleutherius 2d7fba3f52 New translations app_en.arb (Japanese) 2026-01-17 05:23:47 +07:00
Zarz Eleutherius e02d8ff2cd New translations app_en.arb (German) 2026-01-17 05:23:46 +07:00
Zarz Eleutherius f8cee25958 New translations app_en.arb (Spanish) 2026-01-17 05:23:45 +07:00
Zarz Eleutherius 99c133aae1 New translations app_en.arb (French) 2026-01-17 05:23:45 +07:00
103 changed files with 16854 additions and 5479 deletions
+2
View File
@@ -6,6 +6,8 @@ Thumbs.db
.idea/
.vscode/
*.iml
.cursorignore
.cursorrules
# Kiro specs (development only)
.kiro/
+173 -14
View File
@@ -1,6 +1,165 @@
# Changelog
## [3.1.0] - 2026-01-19
## [3.1.2] - 2026-01-19
### Added
- **New Languages**: Added Spanish (es) and Portuguese (pt) translations
- 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
- Lists all enabled extensions with custom search capability
- Displays extension icons when available
- Checkmark indicates currently selected provider
- 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
- Tapping navigates directly to the downloaded album screen
- Shows the most recent download time for each album
### Changed
- **FFmpeg FLAC-to-MP3 Conversion**: Improved conversion process
- 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
- Matches modern app behavior for better readability
### Fixed
- **MP3 Quality Display in Track Metadata**: Fixed incorrect quality display for MP3 files
- 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
- Plurals now correctly handle Russian grammar (1 трек, 2 трека, 5 треков)
### Dependencies
- Added `palette_generator: ^0.3.3+4` for cover art color extraction
---
## [3.1.1] - 2026-01-17
### Added
- **Lyrics Caching**: Lyrics are now cached for 24 hours to reduce API calls and improve performance
- Thread-safe cache with automatic expiration
- Cache key based on artist, track, and duration
- Log indicator shows "(cached)" when lyrics are served from cache
- **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching
- Compares track duration with lrclib.net results
- 10-second tolerance to handle version differences (radio edit, remaster, etc.)
- Prioritizes synced lyrics over plain text when duration matches
- Falls back gracefully if no duration match found
- **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality
- Detects Deezer CDN URLs (`cdn-images.dzcdn.net`)
- Upgrades cover resolution to 1800x1800 (max available)
- Works alongside existing cover upgrade
- **Live Search for Extensions**: Search-as-you-type functionality for extension search
- 800ms debounce delay to prevent excessive API calls
- Minimum 3 characters required before searching
- Concurrency control to prevent race conditions in extension runtime
- Queues pending searches if a search is already in progress
- **Russian Language Support**: Added Russian (Русский) translation - 99% complete
- Translated via Crowdin community contributions
- Covers all UI elements, settings, and error messages
### Fixed
- **ISRC Index Race Condition**: Fixed repeated index rebuilding during parallel downloads
- Added per-directory build lock using `sync.Map` and `sync.Mutex`
- Double-check locking pattern ensures index is built only once
- Significantly improves performance during CSV import with many tracks
- **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView
- Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion
- Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors
- Issue was especially noticeable during rapid queue updates (CSV import)
- **CSV Import**: Fixed CSV export not being parsed correctly
- Added support for `Artist Name(s)` header (with parentheses)
- Added support for `Track URI` header for track IDs
- Added `artists` and `track_id` as alternative header names
- Now correctly parses "Liked Songs" and playlist exports
---
## [3.1.0] - 2026-01-16
### Added
@@ -105,17 +264,17 @@
- YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs
- Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter
- `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen`
- `ArtistScreen` with `extensionId` skips Spotify/Deezer fetch, uses extension data only (fixes "Rate Limited" errors)
- `ArtistScreen` with `extensionId` skips metadata fetch, uses extension data only (fixes "Rate Limited" errors)
- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field
- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus
- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, Spotify Web) instead of trying to fetch from Spotify API
- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, etc.) instead of trying to fetch from API
### Extensions
- **YouTube Music Extension**: Updated to v1.5.0
- `getArtist()` now returns `top_tracks` array with popular songs
- Added `header_image` and `listeners` to artist response
- **Spotify Web Extension**: Updated to v1.6.0
- **Web Extension**: Updated to v1.6.0
### Localization
@@ -148,12 +307,12 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- One-tap install, update, and uninstall
- Offline cache for browsing without internet
#### Spotify Web Extension
#### Web Extension
- Available in Extension Store - install and enable in Settings > Extensions
- Metadata provider using Spotify's internal web player API
- Metadata provider using web player API
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
- Useful when official Spotify API is rate-limited or unavailable
- Useful when official API is rate-limited or unavailable
#### Extension Capabilities
@@ -188,7 +347,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
- Based on `album_type` from Spotify/Deezer metadata
- Based on `album_type` from metadata
- Toggle in Settings > Download > Separate Singles Folder
- **Year in Album Folder Name**: New album folder structure options with release year
@@ -226,7 +385,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from Deezer/Spotify selector in Options
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from provider selector in Options
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
@@ -261,7 +420,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
- Detects existing entries by track ID, Deezer ID, or ISRC
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
@@ -330,7 +489,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
- `_performSearch` now checks if extension is still enabled before calling custom search
- Automatically falls back to Deezer/Spotify search if extension was disabled
- Automatically falls back to Deezer search if extension was disabled
- Clears `searchProvider` setting if extension no longer available
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
@@ -450,7 +609,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
### Extensions
- **Spotify Web Extension** (example): New extension for Spotify metadata via web API
- **Web Extension** (example): New extension for metadata via web API
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
- Search, album, playlist, track, and artist fetching
- Available in Extension Store (3.0.0-alpha.4)
@@ -462,7 +621,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
### Added
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
- Based on `album_type` from Spotify/Deezer metadata
- Based on `album_type` from metadata
- Toggle in Settings > Download > Separate Singles Folder
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
- **Browser-like Polyfills**: New global APIs for easier library porting
@@ -482,7 +641,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
### Fixed
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
- Detects existing entries by track ID, Deezer ID, or ISRC
- Replaces existing entry and moves to top of list
- Auto-deduplicates existing history on app load
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
+9 -10
View File
@@ -6,7 +6,7 @@
<img src="icon.png" width="128" />
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
@@ -26,12 +26,12 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
## Search Source
SpotiFLAC supports two search sources:
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
| **Extensions** | Install additional search providers from the Store |
## Extensions
@@ -50,7 +50,7 @@ Want to create your own extension? Check out the [Extension Development Guide](h
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## FAQ
@@ -60,15 +60,12 @@ A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
**Q: Can I download my Spotify playlists?**
A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Can I download playlists?**
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
**Q: How do I download Daily Mix or Discover Weekly?**
A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API.
**Q: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
@@ -78,7 +75,9 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
The application is purely a user interface that facilitates communication between your device and existing third-party services.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
@@ -158,8 +158,9 @@ class MainActivity: FlutterActivity() {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
Gobackend.fetchLyrics(spotifyId, trackName, artistName)
Gobackend.fetchLyrics(spotifyId, trackName, artistName, durationMs)
}
result.success(response)
}
@@ -168,8 +169,9 @@ class MainActivity: FlutterActivity() {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val filePath = call.argument<String>("file_path") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs)
}
result.success(response)
}
@@ -282,6 +284,13 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"getDeezerExtendedMetadata" -> {
val trackId = call.argument<String>("track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerExtendedMetadata(trackId)
}
result.success(response)
}
"convertSpotifyToDeezer" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
@@ -436,6 +445,14 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"invokeExtensionAction" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val actionName = call.argument<String>("action") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.invokeExtensionActionJSON(extensionId, actionName)
}
result.success(response)
}
"searchTracksWithExtensions" -> {
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 20
+134 -7
View File
@@ -42,17 +42,27 @@ class FFmpegServiceIOS {
}
/// Convert FLAC to MP3
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
final dir = File(inputPath).parent.path;
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}MP3';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
/// If deleteOriginal is true, deletes the FLAC file after conversion
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
bool deleteOriginal = true,
}) async {
// Convert in same folder, just change extension
final outputPath = inputPath.replaceAll('.flac', '.mp3');
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final result = await _execute(command);
if (result.success) return outputPath;
if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
_log.e('FLAC to MP3 conversion failed: ${result.output}');
return null;
}
@@ -177,6 +187,123 @@ class FFmpegServiceIOS {
return null;
}
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({
required String mp3Path,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempOutput = '$mp3Path.tmp';
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 ');
cmdBuffer.write('-id3v2_version 3 ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command');
final result = await _execute(command);
if (result.success) {
try {
await File(mp3Path).delete();
await File(tempOutput).rename(mp3Path);
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
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;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
break;
case 'ARTIST':
id3Map['artist'] = value;
break;
case 'ALBUM':
id3Map['album'] = value;
break;
case 'ALBUMARTIST':
id3Map['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
id3Map['disc'] = value;
break;
case 'DATE':
case 'YEAR':
id3Map['date'] = value;
break;
case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
/// Check if FFmpeg is available
static Future<bool> isAvailable() async {
try {
+17 -1
View File
@@ -1,3 +1,19 @@
files:
- source: /lib/l10n/arb/app_en.arb
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Short codes for single-variant languages
de: de
es: es
fr: fr
hi: hi
id: id
ja: ja
ko: ko
nl: nl
pt: pt
ru: ru
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
+6 -38
View File
@@ -1,8 +1,8 @@
package gobackend
import (
"context"
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
@@ -27,10 +27,9 @@ type AmazonDownloader struct {
}
var (
// Global Amazon downloader instance for connection reuse
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
amazonRateLimitMu sync.Mutex
)
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
@@ -55,17 +54,14 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
@@ -80,13 +76,10 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
expectedASCII := amazonIsASCIIString(expectedArtist)
foundASCII := amazonIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
@@ -127,7 +120,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
now := time.Now()
// Reset counter every minute
if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0
a.apiCallResetTime = now
@@ -155,7 +147,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
}
}
// Update tracking
a.lastAPICallTime = time.Now()
a.apiCallCount++
}
@@ -181,8 +172,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
for _, region := range a.regions {
GoLog("[Amazon] Trying region: %s...\n", region)
// Build base URL for DoubleDouble service
// Decode base64 service URL (same as PC)
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
@@ -301,7 +290,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
if status.Status == "done" {
fmt.Println("\n[Amazon] Download ready!")
// Build download URL
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
@@ -383,7 +371,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
}
expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -393,16 +380,13 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return err
}
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body)
}
@@ -410,7 +394,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
@@ -456,24 +439,19 @@ type AmazonDownloadResult struct {
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Get Amazon URL from SongLink
songlink := NewSongLinkClient()
var availability *TrackAvailability
var err error
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
if strings.HasPrefix(req.SpotifyID, "deezer:") {
// Extract Deezer ID and use Deezer-based lookup
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
// Use Spotify ID
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
@@ -487,7 +465,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
// Create output directory if needed
if req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
@@ -506,10 +483,8 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
}
// Log match found
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
// Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -521,7 +496,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
@@ -538,6 +512,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
@@ -552,8 +527,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
@@ -564,14 +537,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
}
// Read existing metadata from downloaded file BEFORE embedding
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
if metaErr == nil && existingMeta != nil {
// Use file metadata if it has valid track/disc numbers and request doesn't have them
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
@@ -594,6 +564,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
}
// Use cover data from parallel fetch
@@ -621,8 +594,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
// Read actual quality from the downloaded FLAC file
// Amazon API doesn't provide quality info, but we can read it from the file itself
quality, err := GetAudioQuality(outputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
@@ -630,8 +601,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
// Read metadata from file AFTER embedding to get accurate values
// This ensures we return what's actually in the file
finalMeta, metaReadErr := ReadMetadata(outputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
@@ -639,7 +608,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
// Use date from file if available
req.ReleaseDate = finalMeta.Date
}
}
-1
View File
@@ -52,7 +52,6 @@ func cancelDownload(itemID string) {
}
cancelMu.Unlock()
// Hide progress for cancelled items.
RemoveItemProgress(itemID)
}
+32 -16
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
@@ -14,6 +15,9 @@ const (
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
)
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
// Same logic as PC version for consistency
func convertSmallToMedium(imageURL string) string {
@@ -32,20 +36,19 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
GoLog("[Cover] Original URL: %s", coverURL)
// First upgrade small (300) to medium (640) - always do this
downloadURL := convertSmallToMedium(coverURL)
if downloadURL != coverURL {
GoLog("[Cover] Upgraded 300x300 → 640x640")
}
// Then upgrade to max quality if requested
if maxQuality {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
} else {
GoLog("[Cover] Max resolution not available, using 640x640")
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
}
}
@@ -53,7 +56,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
client := NewHTTPClientWithTimeout(DefaultTimeout)
// Create request with User-Agent (required by Spotify CDN)
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -74,8 +76,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err)
}
// Calculate approximate resolution from file size
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
sizeKB := len(data) / 1024
var resolution string
if sizeKB > 200 {
@@ -90,22 +90,38 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return data, nil
}
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
// Same logic as PC version - directly replaces 640x640 size code with max resolution
// No HEAD verification needed - Spotify CDN always serves max resolution if available
// upgradeToMaxQuality upgrades cover URL to maximum quality
// Supports both Spotify and Deezer CDNs
func upgradeToMaxQuality(coverURL string) string {
// Spotify image URLs can be upgraded by changing the size parameter
// Format: https://i.scdn.co/image/ab67616d0000b273...
// ab67616d0000b273 = 640x640
// ab67616d000082c1 = Max resolution (~2000x2000)
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
return coverURL
}
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
// Available sizes: 56, 250, 500, 1000, 1400, 1800
func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return coverURL
}
// Replace any size pattern with 1800x1800
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800")
}
return upgraded
}
// GetCoverFromSpotify gets cover URL from Spotify metadata
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
+108 -16
View File
@@ -22,8 +22,7 @@ const (
deezerCacheTTL = 10 * time.Minute
// Parallel ISRC fetching settings
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
deezerMaxParallelISRC = 10
)
// DeezerClient handles Deezer API interactions (no auth required)
@@ -36,7 +35,6 @@ type DeezerClient struct {
cacheMu sync.RWMutex
}
// Singleton instance
var (
deezerClient *DeezerClient
deezerClientOnce sync.Once
@@ -113,7 +111,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
albumImage = track.Album.Cover
}
// Try to find release date
releaseDate := track.ReleaseDate
if releaseDate == "" {
releaseDate = track.Album.ReleaseDate
@@ -135,16 +132,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
}
}
type deezerGenre struct {
ID int `json:"id"`
Name string `json:"name"`
}
type deezerAlbumFull struct {
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"` // album, single, ep, compile
ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"` // album, single, ep, compile
Label string `json:"label"` // Record label name
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"`
Tracks struct {
@@ -313,12 +319,23 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
artistName = strings.Join(names, ", ")
}
// Extract genres as comma-separated string
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
genreStr := strings.Join(genres, ", ")
info := AlbumInfoMetadata{
TotalTracks: album.NbTracks,
Name: album.Title,
ReleaseDate: album.ReleaseDate,
Artists: artistName,
Images: albumImage,
Genre: genreStr, // From Deezer album
Label: album.Label, // From Deezer album
}
// Fetch ISRCs in parallel
@@ -541,7 +558,6 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
return &result, nil
}
// Check if we got a valid response (ID > 0)
if track.ID == 0 {
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
}
@@ -564,7 +580,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
result := make(map[string]string)
var resultMu sync.Mutex
// First, check cache for existing ISRCs
var tracksToFetch []deezerTrack
c.cacheMu.RLock()
for _, track := range tracks {
@@ -622,7 +637,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
// GetTrackISRC fetches ISRC for a single track (with caching)
// Use this when you need ISRC for download
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
// Check cache first
c.cacheMu.RLock()
if isrc, ok := c.isrcCache[trackID]; ok {
c.cacheMu.RUnlock()
@@ -683,6 +697,84 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
return album.Cover
}
// AlbumExtendedMetadata contains genre and label information from an album
type AlbumExtendedMetadata struct {
Genre string // Comma-separated list of genres
Label string // Record label name
}
// GetAlbumExtendedMetadata fetches genre and label from a Deezer album
// Uses the album ID from a track to fetch extended metadata
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
if albumID == "" {
return nil, fmt.Errorf("empty album ID")
}
// Check cache first
cacheKey := fmt.Sprintf("album_meta:%s", albumID)
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*AlbumExtendedMetadata), nil
}
c.cacheMu.RUnlock()
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
var album deezerAlbumFull
if err := c.getJSON(ctx, albumURL, &album); err != nil {
return nil, fmt.Errorf("failed to fetch album: %w", err)
}
// Extract genres as comma-separated string
var genres []string
for _, g := range album.Genres.Data {
if g.Name != "" {
genres = append(genres, g.Name)
}
}
result := &AlbumExtendedMetadata{
Genre: strings.Join(genres, ", "),
Label: album.Label,
}
// Cache the result
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
}
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
return result, nil
}
// GetTrackAlbumID fetches the album ID for a Deezer track
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
var track deezerTrack
if err := c.getJSON(ctx, trackURL, &track); err != nil {
return "", err
}
return fmt.Sprintf("%d", track.Album.ID), nil
}
// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID
// This is a convenience function that first gets the album ID, then fetches album metadata
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
albumID, err := c.GetTrackAlbumID(ctx, trackID)
if err != nil {
return nil, fmt.Errorf("failed to get album ID: %w", err)
}
return c.GetAlbumExtendedMetadata(ctx, albumID)
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
+20 -14
View File
@@ -18,30 +18,45 @@ type ISRCIndex struct {
mu sync.RWMutex
}
// Global ISRC index cache (per output directory)
var (
isrcIndexCache = make(map[string]*ISRCIndex)
isrcIndexCacheMu sync.RWMutex
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
isrcIndexTTL = 5 * time.Minute
)
// GetISRCIndex returns or builds an ISRC index for the given directory
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
// Return cached index if still valid
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
// Build new index
// Slow path: need to build index
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// Double-check cache after acquiring lock (another goroutine may have built it)
isrcIndexCacheMu.RLock()
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
return buildISRCIndex(outputDir)
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
// Same implementation as PC version for consistency
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
@@ -56,7 +71,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
startTime := time.Now()
fileCount := 0
// Walk directory - only check .flac files
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
@@ -67,13 +81,11 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return nil
}
// Read ISRC from file
metadata, err := ReadMetadata(path)
if err != nil || metadata.ISRC == "" {
return nil
}
// Store in index (uppercase for case-insensitive matching)
idx.index[strings.ToUpper(metadata.ISRC)] = path
fileCount++
return nil
@@ -82,7 +94,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
// Cache the index
isrcIndexCacheMu.Lock()
isrcIndexCache[outputDir] = idx
isrcIndexCacheMu.Unlock()
@@ -90,7 +101,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// lookup checks if an ISRC exists in the index (internal, returns bool)
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
if isrc == "" {
return "", false
@@ -193,7 +203,6 @@ type FileExistenceResult struct {
// It builds an ISRC index from the output directory once, then checks all tracks against it
// Same implementation as PC version for consistency
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
// Parse input JSON
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
@@ -205,10 +214,8 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
results := make([]FileExistenceResult, len(tracks))
// Build ISRC index from output directory (scan once)
isrcIdx := GetISRCIndex(outputDir)
// Check each track against the index (parallel)
var wg sync.WaitGroup
for i, track := range tracks {
wg.Add(1)
@@ -239,7 +246,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
wg.Wait()
// Return results as JSON
resultJSON, err := json.Marshal(results)
if err != nil {
return "", fmt.Errorf("failed to marshal results: %w", err)
+60 -55
View File
@@ -153,6 +153,10 @@ type DownloadRequest struct {
ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
// Extended metadata from Deezer for FLAC tagging
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated
Label string `json:"label,omitempty"` // Record label name
Copyright string `json:"copyright,omitempty"` // Copyright information
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
@@ -184,7 +188,6 @@ type DownloadResponse struct {
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
}
// DownloadResult is a generic result type for all downloaders
// DownloadResult is a generic result type for all downloaders
type DownloadResult struct {
FilePath string
@@ -283,10 +286,8 @@ func DownloadTrack(requestJSON string) (string, error) {
return errorResponse(err.Error())
}
// Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -312,7 +313,6 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -362,7 +362,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
AddAllowedDownloadDir(req.OutputDir)
}
// Build service order starting with preferred service
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
if preferredService == "" {
@@ -371,7 +370,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
// Create ordered list: preferred first, then others
services := []string{preferredService}
for _, s := range allServices {
if s != preferredService {
@@ -455,10 +453,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
}
if err == nil {
// Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -484,7 +480,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -539,7 +534,6 @@ func InitItemProgress(itemID string) {
// FinishItemProgress marks a download item as complete and removes tracking
func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID)
// Don't remove immediately - let Flutter poll one more time to see 100%
}
// ClearItemProgress removes progress tracking for a specific item
@@ -567,10 +561,8 @@ func ReadFileMetadata(filePath string) (string, error) {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
// Also get audio quality info
quality, qualityErr := GetAudioQuality(filePath)
// Get duration from FLAC stream info
duration := 0
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
duration = int(quality.TotalSamples / int64(quality.SampleRate))
@@ -589,7 +581,6 @@ func ReadFileMetadata(filePath string) (string, error) {
"duration": duration,
}
// Add quality info if available
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
@@ -640,7 +631,6 @@ func PreBuildDuplicateIndex(outputDir string) error {
}
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
// Call this when files are deleted or moved
func InvalidateDuplicateIndex(outputDir string) {
InvalidateISRCCache(outputDir)
}
@@ -663,9 +653,11 @@ func SanitizeFilename(filename string) string {
// FetchLyrics fetches lyrics for a track from LRCLIB
// Returns JSON with lyrics data
func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
@@ -687,8 +679,8 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
// First tries to extract from file, then falls back to fetching from internet
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
// Try to extract from file first (much faster)
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
@@ -696,14 +688,13 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
}
}
// Fallback to fetching from internet
client := NewLyricsClient()
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
// Convert to LRC format with metadata headers (like PC version)
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
@@ -740,7 +731,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
return errorResponse("Invalid JSON: " + err.Error())
}
// Convert to PreWarmCacheRequest
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
@@ -752,7 +742,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
}
}
// Run in background
go PreWarmTrackCache(requests)
resp := map[string]interface{}{
@@ -852,6 +841,37 @@ func ParseDeezerURLExport(url string) (string, error) {
return string(jsonBytes), nil
}
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
// trackID: Deezer track ID (will look up album ID from track)
// Returns JSON with genre, label fields
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
if err != nil {
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
return "", err
}
result := map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// SearchDeezerByISRC searches for a track by ISRC on Deezer
func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -872,7 +892,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
}
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer
// Useful when Spotify API is rate limited
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -881,14 +900,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
songlink := NewSongLinkClient()
deezerClient := GetDeezerClient()
// For tracks, we can use SongLink to get Deezer ID
if resourceType == "track" {
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
}
// Fetch metadata from Deezer
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
@@ -902,14 +919,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return string(jsonBytes), nil
}
// For albums, SongLink also provides mapping
if resourceType == "album" {
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer album: %w", err)
}
// Fetch album metadata from Deezer
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
@@ -932,10 +947,8 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Try Spotify first
client, err := NewSpotifyMetadataClient()
if err != nil {
// No Spotify credentials - fall through to Deezer fallback
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
} else {
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
@@ -947,15 +960,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return string(jsonBytes), nil
}
// Check if it's a rate limit error
errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
// Not a rate limit error, return original error
return "", err
}
}
// Rate limited - try Deezer fallback for tracks and albums
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
@@ -964,11 +974,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" {
// Convert to Deezer
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
}
// Artist and playlist not supported for fallback
if parsed.Type == "artist" {
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
}
@@ -1033,7 +1041,6 @@ func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
}
func errorResponse(msg string) (string, error) {
// Determine error type based on message
errorType := "unknown"
lowerMsg := strings.ToLower(msg)
@@ -1122,7 +1129,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return "", err
}
// Initialize with saved settings
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
@@ -1273,7 +1279,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
return err
}
// Re-initialize extension with new settings
manager := GetExtensionManager()
return manager.InitializeExtension(extensionID, settings)
}
@@ -1320,6 +1325,23 @@ func CleanupExtensions() {
manager.UnloadAllExtensions()
}
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
manager := GetExtensionManager()
result, err := manager.InvokeAction(extensionID, actionName)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// ==================== EXTENSION AUTH API ====================
// GetExtensionPendingAuthJSON returns pending auth request for an extension
@@ -1372,7 +1394,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
return false
}
// Check if token is expired
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return false
}
@@ -1469,7 +1490,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
}
if !ext.Manifest.IsMetadataProvider() {
// Not a metadata provider, return original
return trackJSON, nil
}
@@ -1481,7 +1501,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
provider := NewExtensionProviderWrapper(ext)
enrichedTrack, err := provider.EnrichTrack(&track)
if err != nil {
// Error enriching, return original
return trackJSON, nil
}
@@ -1518,7 +1537,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
return "", err
}
// Convert to map format for Flutter, ensuring images field is set
result := make([]map[string]interface{}, len(tracks))
for i, track := range tracks {
result[i] = map[string]interface{}{
@@ -1585,12 +1603,10 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
result := resultWithID.Result
extensionID := resultWithID.ExtensionID
// Check if result is nil (handler found but returned error)
if result == nil {
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
}
// Build response
response := map[string]interface{}{
"type": result.Type,
"extension_id": extensionID,
@@ -1598,7 +1614,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"cover_url": result.CoverURL,
}
// Add track if single track
if result.Track != nil {
response["track"] = map[string]interface{}{
"id": result.Track.ID,
@@ -1616,7 +1631,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
}
}
// Add tracks if multiple
if len(result.Tracks) > 0 {
tracks := make([]map[string]interface{}, len(result.Tracks))
for i, track := range result.Tracks {
@@ -1654,7 +1668,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
}
}
// Add artist info if present
if result.Artist != nil {
artistResponse := map[string]interface{}{
"id": result.Artist.ID,
@@ -1665,7 +1678,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"provider_id": result.Artist.ProviderID,
}
// Add albums if present
if len(result.Artist.Albums) > 0 {
albums := make([]map[string]interface{}, len(result.Artist.Albums))
for i, album := range result.Artist.Albums {
@@ -1688,7 +1700,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
artistResponse["albums"] = albums
}
// Add top tracks if present
if len(result.Artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
for i, track := range result.Artist.TopTracks {
@@ -1758,10 +1769,8 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
return "", fmt.Errorf("album not found")
}
// Convert tracks to map format
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
// Use album cover as fallback if track doesn't have its own cover
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
@@ -1818,7 +1827,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
provider := NewExtensionProviderWrapper(ext)
// Try getPlaylist first, fall back to getAlbum (some extensions use album for playlists)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
@@ -1856,10 +1864,8 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
album.Tracks[i].ProviderID = ext.ID
}
// Convert tracks to map format
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
// Use playlist cover as fallback if track doesn't have its own cover
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
@@ -1922,7 +1928,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
return "", fmt.Errorf("artist not found")
}
// Convert albums to map format
albums := make([]map[string]interface{}, len(artist.Albums))
for i, album := range artist.Albums {
albums[i] = map[string]interface{}{
+61 -55
View File
@@ -18,11 +18,9 @@ import (
// compareVersions compares two semantic version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int {
// Parse version parts
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
// Pad shorter version with zeros
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
@@ -52,12 +50,12 @@ func compareVersions(v1, v2 string) int {
type LoadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized)
VM *goja.Runtime `json:"-"`
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"` // Extension's data directory
SourceDir string `json:"source_dir"` // Where extension files are extracted
IconPath string `json:"icon_path"` // Full path to icon file (if exists)
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
// ExtensionManager manages all loaded extensions
@@ -68,7 +66,6 @@ type ExtensionManager struct {
dataDir string // Base directory for extension data
}
// Global extension manager instance
var (
globalExtManager *ExtensionManager
globalExtManagerOnce sync.Once
@@ -92,7 +89,6 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.extensionsDir = extensionsDir
m.dataDir = dataDir
// Create directories if they don't exist
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
return fmt.Errorf("failed to create extensions directory: %w", err)
}
@@ -117,7 +113,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte
var hasIndexJS bool
for _, file := range zipReader.File {
@@ -146,13 +141,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("Invalid extension package: index.js not found")
}
// Parse and validate manifest
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
// Check if extension already loaded - if so, try upgrade (check without holding lock for long)
m.mu.RLock()
existing, exists := m.extensions[manifest.Name]
var existingVersion string
@@ -164,7 +157,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
m.mu.RUnlock()
if exists {
// Check if this is an upgrade
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
// This is an upgrade - call UpgradeExtension
@@ -176,16 +168,13 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
}
// Now acquire write lock for the rest of the operation
m.mu.Lock()
defer m.mu.Unlock()
// Double-check extension wasn't added while we were waiting for lock
if _, exists := m.extensions[manifest.Name]; exists {
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
}
// Create extension directory
extDir := filepath.Join(m.extensionsDir, manifest.Name)
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
@@ -206,19 +195,16 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
destPath := filepath.Join(extDir, relPath)
// Create parent directories if needed
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
// Create destination file
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
// Copy content
srcFile, err := file.Open()
if err != nil {
destFile.Close()
@@ -233,13 +219,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
}
// Create data directory for extension
extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
// Create loaded extension
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
@@ -263,23 +247,19 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
// initializeVM creates and initializes the Goja VM for an extension
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
// Create new Goja runtime
vm := goja.New()
ext.VM = vm
// Read index.js
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
// Create extension runtime and register sandboxed APIs
runtime := NewExtensionRuntime(ext)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
// Set up console.log for debugging
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
@@ -291,12 +271,10 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
})
vm.Set("console", console)
// Set up registerExtension function
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
// Also set it as global 'extension' variable for later access
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
@@ -406,7 +384,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
for _, entry := range entries {
if entry.IsDir() {
// Check if it's an extracted extension directory
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
if _, err := os.Stat(manifestPath); err == nil {
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
@@ -418,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
}
}
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
// Load from package file
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
@@ -437,7 +413,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
m.mu.Lock()
defer m.mu.Unlock()
// Read manifest
manifestPath := filepath.Join(dirPath, "manifest.json")
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
@@ -450,25 +425,21 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
// Check if index.js exists
indexPath := filepath.Join(dirPath, "index.js")
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
return nil, fmt.Errorf("Extension is missing index.js file")
}
// Check if extension already loaded - skip silently (for directory loading on startup)
if existing, exists := m.extensions[manifest.Name]; exists {
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
return existing, nil
}
// Create data directory for extension
extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
// Create loaded extension
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
@@ -541,7 +512,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte
var hasIndexJS bool
for _, file := range zipReader.File {
@@ -570,13 +540,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return nil, fmt.Errorf("Invalid extension package: index.js not found")
}
// Parse and validate manifest
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
// Check if extension exists
m.mu.RLock()
existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock()
@@ -612,19 +580,15 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
}
// Recreate extension directory
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
}
// Extract all files from new package (preserving directory structure)
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
// Preserve relative path within the zip (support subdirectories)
// Clean the path to prevent path traversal attacks
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
@@ -632,19 +596,16 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
destPath := filepath.Join(extDir, relPath)
// Create parent directories if needed
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
// Create destination file
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
// Copy content
srcFile, err := file.Open()
if err != nil {
destFile.Close()
@@ -659,7 +620,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
}
// Create new loaded extension (reusing data directory, preserving enabled state)
ext := &LoadedExtension{
ID: newManifest.Name,
Manifest: newManifest,
@@ -708,7 +668,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
}
defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte
for _, file := range zipReader.File {
name := filepath.Base(file.Name)
@@ -730,13 +689,11 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return nil, fmt.Errorf("manifest.json not found")
}
// Parse manifest
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid manifest: %w", err)
}
// Check if extension exists
m.mu.RLock()
existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock()
@@ -752,7 +709,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
info.CurrentVersion = ""
info.CanUpgrade = false
} else {
// Compare versions
info.CurrentVersion = existing.Manifest.Version
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
}
@@ -805,7 +761,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
infos := make([]ExtensionInfo, len(extensions))
for i, ext := range extensions {
// Build permissions list
permissions := []string{}
for _, domain := range ext.Manifest.Permissions.Network {
permissions = append(permissions, "network:"+domain)
@@ -822,7 +777,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
status = "disabled"
}
// Check for icon file
iconPath := ""
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
@@ -830,7 +784,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
iconPath = possibleIcon
}
}
// Fallback: check for icon.png if not specified in manifest
if iconPath == "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
if _, err := os.Stat(possibleIcon); err == nil {
@@ -887,13 +840,11 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
// Convert settings to JSON for passing to JS
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("Failed to save settings")
}
// Call initialize function
script := fmt.Sprintf(`
(function() {
var settings = %s;
@@ -917,7 +868,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return err
}
// Check result
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
@@ -973,7 +923,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return err
}
// Check result
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
@@ -1010,3 +959,60 @@ func (m *ExtensionManager) UnloadAllExtensions() {
GoLog("[Extension] All extensions unloaded\n")
}
// InvokeAction calls a custom action function on an extension (e.g., for button settings)
// The function is called as extension.<actionName>() and can return a result
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
m.mu.Lock()
defer m.mu.Unlock()
ext, exists := m.extensions[extensionID]
if !exists {
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
if ext.VM == nil {
return nil, fmt.Errorf("extension VM not initialized")
}
if !ext.Enabled {
return nil, fmt.Errorf("extension is disabled")
}
// Call the action function on the extension object
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' };
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
if err != nil {
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
return nil, fmt.Errorf("action failed: %v", err)
}
if result == nil || goja.IsUndefined(result) {
return map[string]interface{}{"success": true}, nil
}
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
return resultMap, nil
}
return map[string]interface{}{"success": true, "result": exported}, nil
}
+11
View File
@@ -23,6 +23,7 @@ const (
SettingTypeNumber SettingType = "number"
SettingTypeBool SettingType = "boolean"
SettingTypeSelect SettingType = "select"
SettingTypeButton SettingType = "button" // Action button that calls a JS function
)
// ExtensionPermissions defines what resources an extension can access
@@ -42,6 +43,7 @@ type ExtensionSetting struct {
Secret bool `json:"secret,omitempty"`
Default interface{} `json:"default,omitempty"`
Options []string `json:"options,omitempty"` // For select type
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
}
// QualityOption represents a quality option for download providers
@@ -204,6 +206,7 @@ func (m *ExtensionManifest) Validate() error {
SettingTypeNumber: true,
SettingTypeBool: true,
SettingTypeSelect: true,
SettingTypeButton: true,
}
if !validTypes[setting.Type] {
return &ManifestValidationError{
@@ -219,6 +222,14 @@ func (m *ExtensionManifest) Validate() error {
Message: "select type requires options",
}
}
// Button type requires action
if setting.Type == SettingTypeButton && setting.Action == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].action", i),
Message: "button type requires action (JS function name)",
}
}
}
return nil
+18 -14
View File
@@ -189,7 +189,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
}
}
// Set provider ID on all tracks
for i := range searchResult.Tracks {
searchResult.Tracks[i].ProviderID = p.extension.ID
}
@@ -737,12 +736,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
enrichedTrack, err := provider.EnrichTrack(trackMeta)
if err == nil && enrichedTrack != nil {
// Update request with enriched data
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
req.ISRC = enrichedTrack.ISRC
}
// Update service-specific IDs from Odesli enrichment
if enrichedTrack.TidalID != "" {
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
req.TidalID = enrichedTrack.TidalID
@@ -755,7 +752,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
req.DeezerID = enrichedTrack.DeezerID
}
// Can also update other fields if needed
if enrichedTrack.Name != "" {
req.TrackName = enrichedTrack.Name
}
@@ -772,7 +768,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
// Check if this extension wants to skip built-in fallback
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
provider := NewExtensionProviderWrapper(ext)
@@ -783,7 +778,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
// Build output path
outputPath := buildOutputPath(req)
// Download directly using the track ID from the extension
@@ -803,6 +797,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Service: req.Source,
}
// 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)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
}
// If extension has skipMetadataEnrichment, copy metadata
if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true
@@ -916,7 +919,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext)
// Check availability first
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
if err != nil || !availability.Available {
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
@@ -926,12 +928,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
// Build output path
outputPath := buildOutputPath(req)
// Download
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
// Update progress
if req.ItemID != "" {
SetItemProgress(req.ItemID, float64(percent), 0, 0)
}
@@ -947,6 +946,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Service: providerID,
}
// 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)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
}
// If extension has skipMetadataEnrichment and returned metadata, use it
if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true
@@ -1171,7 +1179,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
tracks = []ExtTrackMetadata{}
}
// Set provider ID on all tracks
for i := range tracks {
tracks[i].ProviderID = p.extension.ID
}
@@ -1255,7 +1262,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
}
}
// Set provider ID on top tracks
for i := range handleResult.Artist.TopTracks {
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
}
@@ -1493,12 +1499,10 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
for _, provider := range providers {
hooks := provider.extension.Manifest.GetPostProcessingHooks()
for _, hook := range hooks {
// Check if hook is enabled (TODO: check user settings)
if !hook.DefaultEnabled {
continue
}
// Check if format is supported
ext := strings.ToLower(filepath.Ext(currentPath))
if len(hook.SupportedFormats) > 0 {
supported := false
-5
View File
@@ -10,10 +10,8 @@ import (
"github.com/dop251/goja"
)
// Default timeout for JS execution (30 seconds)
const DefaultJSTimeout = 30 * time.Second
// Global auth state for extensions (stores pending auth codes)
var (
extensionAuthState = make(map[string]*ExtensionAuthState)
extensionAuthStateMu sync.RWMutex
@@ -39,7 +37,6 @@ type PendingAuthRequest struct {
CallbackURL string
}
// Global pending auth requests (Flutter polls this)
var (
pendingAuthRequests = make(map[string]*PendingAuthRequest)
pendingAuthRequestsMu sync.RWMutex
@@ -52,7 +49,6 @@ func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
return pendingAuthRequests[extensionID]
}
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
func ClearPendingAuthRequest(extensionID string) {
pendingAuthRequestsMu.Lock()
defer pendingAuthRequestsMu.Unlock()
@@ -101,7 +97,6 @@ type ExtensionRuntime struct {
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
// Create a cookie jar for this extension
jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{
-7
View File
@@ -11,23 +11,18 @@ var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(filename string) string {
// Replace invalid characters with underscore
sanitized := invalidChars.ReplaceAllString(filename, "_")
// Remove leading/trailing spaces and dots
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".")
// Collapse multiple underscores
multiUnderscore := regexp.MustCompile(`_+`)
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
// Limit length (Android has 255 byte limit for filenames)
if len(sanitized) > 200 {
sanitized = sanitized[:200]
}
// Ensure not empty
if sanitized == "" {
sanitized = "untitled"
}
@@ -43,7 +38,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
result := template
// Replace placeholders
placeholders := map[string]string{
"{title}": getString(metadata, "title"),
"{artist}": getString(metadata, "artist"),
@@ -63,7 +57,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
// Trim leading/trailing whitespace to prevent filename issues
return strings.TrimSpace(s)
}
}
+13 -26
View File
@@ -20,13 +20,11 @@ import (
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
func getRandomUserAgent() string {
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
// Some APIs may block mobile User-Agents, so we use desktop format
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
winMajor := rand.Intn(2) + 10
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
chromePatch := rand.Intn(65) + 60 // Patch 60-125
chromeVersion := rand.Intn(25) + 100
chromeBuild := rand.Intn(1500) + 3000
chromePatch := rand.Intn(65) + 60
return fmt.Sprintf(
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
@@ -39,7 +37,6 @@ func getRandomUserAgent() string {
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
// Kept for potential future use
// func getRandomMacUserAgent() string {
// macMajor := rand.Intn(4) + 11 // macOS 11-14
// macMinor := rand.Intn(5) + 4 // Minor 4-8
@@ -66,7 +63,6 @@ func getRandomUserAgent() string {
// }
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
// Kept for potential future use
// func getRandomDesktopUserAgent() string {
// if rand.Intn(2) == 0 {
// return getRandomUserAgent() // Windows
@@ -74,17 +70,15 @@ func getRandomUserAgent() string {
// return getRandomMacUserAgent() // Mac
// }
// Default timeout values
const (
DefaultTimeout = 60 * time.Second // Default HTTP timeout
DownloadTimeout = 120 * time.Second // Timeout for file downloads
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
DefaultMaxRetries = 3 // Default retry count
DefaultRetryDelay = 1 * time.Second // Initial retry delay
DefaultTimeout = 60 * time.Second
DownloadTimeout = 120 * time.Second
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
)
// Shared transport with connection pooling to prevent TCP exhaustion
// Optimized for large file downloads (FLAC ~30-50MB)
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -96,27 +90,24 @@ var sharedTransport = &http.Transport{
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, // 64KB write buffer
ReadBufferSize: 64 * 1024, // 64KB read buffer
DisableCompression: true, // FLAC is already compressed
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: true,
}
// Shared HTTP client for general requests (reuses connections)
var sharedClient = &http.Client{
Transport: sharedTransport,
Timeout: DefaultTimeout,
}
// Shared HTTP client for downloads (longer timeout, reuses connections)
var downloadClient = &http.Client{
Transport: sharedTransport,
Timeout: DownloadTimeout,
}
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
// Uses shared transport for connection reuse
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Transport: sharedTransport,
@@ -124,18 +115,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
}
}
// GetSharedClient returns the shared HTTP client for general requests
func GetSharedClient() *http.Client {
return sharedClient
}
// GetDownloadClient returns the shared HTTP client for downloads
func GetDownloadClient() *http.Client {
return downloadClient
}
// CloseIdleConnections closes idle connections in the shared transport
// Call this periodically during large batch downloads to prevent connection buildup
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
}
@@ -146,7 +134,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
// Check for ISP blocking
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
+1 -5
View File
@@ -21,7 +21,7 @@ type LogBuffer struct {
entries []LogEntry
maxSize int
mu sync.RWMutex
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
loggingEnabled bool
}
var (
@@ -60,7 +60,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock()
defer lb.mu.Unlock()
// Skip if logging is disabled (except for errors which are always logged)
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
return
}
@@ -73,12 +72,10 @@ func (lb *LogBuffer) Add(level, tag, message string) {
}
if len(lb.entries) >= lb.maxSize {
// Remove oldest entry
lb.entries = lb.entries[1:]
}
lb.entries = append(lb.entries, entry)
// Also print to logcat for debugging
fmt.Printf("[%s] %s\n", tag, message)
}
@@ -91,7 +88,6 @@ func (lb *LogBuffer) GetAll() string {
return string(jsonBytes)
}
// getSince returns log entries since the given index (internal use)
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
lb.mu.RLock()
defer lb.mu.RUnlock()
+155 -9
View File
@@ -3,14 +3,100 @@ package gobackend
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// ========================================
// Lyrics Cache with TTL
// ========================================
const (
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours
durationToleranceSec = 10.0 // Duration matching tolerance in seconds
)
type lyricsCacheEntry struct {
response *LyricsResponse
expiresAt time.Time
}
type lyricsCache struct {
mu sync.RWMutex
cache map[string]*lyricsCacheEntry
}
var globalLyricsCache = &lyricsCache{
cache: make(map[string]*lyricsCacheEntry),
}
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
// Normalize key: lowercase, trim spaces
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
// Round duration to nearest 10 seconds for cache key
roundedDuration := math.Round(durationSec/10) * 10
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
}
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.generateKey(artist, track, durationSec)
entry, exists := c.cache[key]
if !exists {
return nil, false
}
// Check if expired
if time.Now().After(entry.expiresAt) {
return nil, false
}
return entry.response, true
}
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.generateKey(artist, track, durationSec)
c.cache[key] = &lyricsCacheEntry{
response: response,
expiresAt: time.Now().Add(lyricsCacheTTL),
}
}
// CleanExpired removes expired entries from cache
func (c *lyricsCache) CleanExpired() int {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
cleaned := 0
for key, entry := range c.cache {
if now.After(entry.expiresAt) {
delete(c.cache, key)
cleaned++
}
}
return cleaned
}
// Size returns current cache size
func (c *lyricsCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -86,7 +172,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
return c.parseLRCLibResponse(&lrcResp), nil
}
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
// durationSec: track duration in seconds, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
baseURL := "https://lrclib.net/api/search"
params := url.Values{}
params.Set("q", query)
@@ -118,6 +206,13 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return nil, fmt.Errorf("no lyrics found")
}
// Filter and score results based on duration matching and synced lyrics
bestMatch := c.findBestMatch(results, durationSec)
if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil
}
// Fallback: return first result with synced lyrics
for _, result := range results {
if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil
@@ -127,38 +222,89 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return c.parseLRCLibResponse(&results[0]), nil
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
// Strategy 1: Direct match with artist and track name
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
// findBestMatch finds the best matching lyrics based on duration and sync status
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
for i := range results {
result := &results[i]
// Check duration match if target duration is provided
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches {
// Prefer synced lyrics over plain
if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil {
bestPlain = result
}
}
}
// Return synced first, then plain
if bestSynced != nil {
return bestSynced
}
return bestPlain
}
// durationMatches checks if two durations are within tolerance
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec
}
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Check cache first
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
cachedCopy.Source = cached.Source + " (cached)"
return &cachedCopy, nil
}
var lyrics *LyricsResponse
var err error
// Try exact match first
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Strategy 2: Try with simplified track name
// Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
// Strategy 3: Search with full query
// Search with duration matching
query := artistName + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Strategy 4: Search with simplified query
// Search with simplified name and duration matching
if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
+77 -75
View File
@@ -24,6 +24,9 @@ type Metadata struct {
ISRC string
Description string
Lyrics string
Genre string // Music genre (e.g., "Rock", "Pop", "Electronic")
Label string // Record label (ORGANIZATION tag in Vorbis)
Copyright string // Copyright information
}
// EmbedMetadata embeds metadata into a FLAC file
@@ -33,7 +36,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find or create vorbis comment block
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
@@ -52,7 +54,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
cmt = flacvorbis.New()
}
// Set metadata fields
setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album)
@@ -84,7 +85,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
// Update or add vorbis comment block
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -92,14 +104,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f.Meta = append(f.Meta, &cmtBlock)
}
// Add cover art if provided
if coverPath != "" {
if fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
} else {
// Remove existing picture blocks first (like PC version)
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -125,7 +135,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
}
}
// Save file
return f.Save(filePath)
}
@@ -137,7 +146,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find or create vorbis comment block
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
@@ -156,7 +164,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
cmt = flacvorbis.New()
}
// Set metadata fields
setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album)
@@ -188,7 +195,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
// Update or add vorbis comment block
if metadata.Genre != "" {
setComment(cmt, "GENRE", metadata.Genre)
}
if metadata.Label != "" {
setComment(cmt, "ORGANIZATION", metadata.Label)
}
if metadata.Copyright != "" {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -196,9 +214,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
f.Meta = append(f.Meta, &cmtBlock)
}
// Add cover art if provided
if len(coverData) > 0 {
// Remove existing picture blocks first
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -220,7 +236,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
}
}
// Save file
return f.Save(filePath)
}
@@ -257,7 +272,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
}
// Also try lowercase variant (some encoders use lowercase)
if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK")
if trackNum != "" {
@@ -269,7 +283,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
}
// Also try DISC variant
if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC")
if discNum != "" {
@@ -277,7 +290,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
}
}
// Try DATE variants
if metadata.Date == "" {
metadata.Date = getComment(cmt, "YEAR")
}
@@ -293,7 +305,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" {
return
}
// Remove existing (case-insensitive comparison for Vorbis comments)
keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- {
comment := cmt.Comments[i]
@@ -305,7 +316,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
}
}
}
// Add new
cmt.Comments = append(cmt.Comments, key+"="+value)
}
@@ -313,7 +323,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
keyUpper := strings.ToUpper(key) + "="
for _, comment := range cmt.Comments {
if len(comment) > len(key) {
// Case-insensitive comparison for Vorbis comments
commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper {
return comment[len(key)+1:]
@@ -323,7 +332,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
return ""
}
// fileExists checks if a file exists
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
@@ -367,6 +375,53 @@ func EmbedLyrics(filePath string, lyrics string) error {
return f.Save(filePath)
}
// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation
// This is used for extension downloads where the file is already downloaded
func EmbedGenreLabel(filePath string, genre, label string) error {
if genre == "" && label == "" {
return nil // Nothing to embed
}
f, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
if genre != "" {
setComment(cmt, "GENRE", genre)
}
if label != "" {
setComment(cmt, "ORGANIZATION", label)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
return f.Save(filePath)
}
// ExtractLyrics extracts embedded lyrics from a FLAC file
func ExtractLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
@@ -381,13 +436,11 @@ func ExtractLyrics(filePath string) (string, error) {
continue
}
// Try LYRICS tag first
lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
// Fallback to UNSYNCEDLYRICS
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
@@ -415,16 +468,12 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
}
defer file.Close()
// Read first 4 bytes to detect file type
marker := make([]byte, 4)
if _, err := file.Read(marker); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
}
// Check if it's a FLAC file
if string(marker) == "fLaC" {
// Continue reading FLAC metadata
// Read metadata block header (4 bytes)
header := make([]byte, 4)
if _, err := file.Read(header); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
@@ -435,19 +484,15 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
}
// Read STREAMINFO block (34 bytes minimum)
streamInfo := make([]byte, 34)
if _, err := file.Read(streamInfo); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
}
// Parse sample rate (20 bits starting at byte 10)
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
// Parse bits per sample (5 bits)
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 |
@@ -461,17 +506,14 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
}, nil
}
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
// First 4 bytes are size, next 4 should be "ftyp"
file.Seek(0, 0) // Reset to beginning
file.Seek(0, 0)
header8 := make([]byte, 8)
if _, err := file.Read(header8); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
}
if string(header8[4:8]) == "ftyp" {
// It's an M4A/MP4 file, use M4A quality reader
file.Close() // Close before calling GetM4AQuality which opens the file again
file.Close()
return GetM4AQuality(filePath)
}
@@ -483,41 +525,33 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
// ========================================
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
// This is a simplified implementation that writes metadata to the file
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
// Read the entire file
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read M4A file: %w", err)
}
// Find moov atom position
moovPos := findAtom(data, "moov", 0)
if moovPos < 0 {
return fmt.Errorf("moov atom not found in M4A file")
}
// Find udta atom inside moov, or create one
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
udtaPos := findAtom(data, "udta", moovPos+8)
// Build new metadata atoms
metaAtom := buildMetaAtom(metadata, coverData)
var newData []byte
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
// udta exists, find meta inside it or replace
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
metaPos := findAtom(data, "meta", udtaPos+8)
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
// Replace existing meta atom
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
newData = append(newData, data[:metaPos]...)
newData = append(newData, metaAtom...)
newData = append(newData, data[metaPos+metaSize:]...)
} else {
// Add meta atom to udta
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
newUdtaSize := 8 + len(newUdtaContent)
newUdta := make([]byte, 4)
@@ -533,7 +567,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
newData = append(newData, data[udtaPos+udtaSize:]...)
}
} else {
// Create new udta with meta
udtaContent := metaAtom
udtaSize := 8 + len(udtaContent)
newUdta := make([]byte, 4)
@@ -544,21 +577,18 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, udtaContent...)
// Insert udta at end of moov
insertPos := moovPos + moovSize
newData = append(newData, data[:insertPos]...)
newData = append(newData, newUdta...)
newData = append(newData, data[insertPos:]...)
}
// Update moov size
newMoovSize := moovSize + len(newData) - len(data)
newData[moovPos] = byte(newMoovSize >> 24)
newData[moovPos+1] = byte(newMoovSize >> 16)
newData[moovPos+2] = byte(newMoovSize >> 8)
newData[moovPos+3] = byte(newMoovSize)
// Write back to file
if err := os.WriteFile(filePath, newData, 0644); err != nil {
return fmt.Errorf("failed to write M4A file: %w", err)
}
@@ -585,55 +615,44 @@ func findAtom(data []byte, name string, offset int) int {
// buildMetaAtom builds a complete meta atom with ilst containing metadata
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
// Build ilst content
var ilst []byte
// ©nam - Title
if metadata.Title != "" {
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
}
// ©ART - Artist
if metadata.Artist != "" {
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
}
// ©alb - Album
if metadata.Album != "" {
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
}
// aART - Album Artist
if metadata.AlbumArtist != "" {
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
}
// ©day - Year/Date
if metadata.Date != "" {
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
}
// trkn - Track Number
if metadata.TrackNumber > 0 {
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
}
// disk - Disc Number
if metadata.DiscNumber > 0 {
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
}
// ©lyr - Lyrics
if metadata.Lyrics != "" {
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
}
// covr - Cover Art
if len(coverData) > 0 {
ilst = append(ilst, buildCoverAtom(coverData)...)
}
// Build ilst atom
ilstSize := 8 + len(ilst)
ilstAtom := make([]byte, 4)
ilstAtom[0] = byte(ilstSize >> 24)
@@ -643,7 +662,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
ilstAtom = append(ilstAtom, []byte("ilst")...)
ilstAtom = append(ilstAtom, ilst...)
// Build hdlr atom (required for meta)
hdlr := []byte{
0, 0, 0, 33, // size = 33
'h', 'd', 'l', 'r',
@@ -656,7 +674,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
0, // null terminator
}
// Build meta atom
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
metaContent = append(metaContent, ilstAtom...)
@@ -676,7 +693,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value)
// data atom
dataSize := 16 + len(valueBytes)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
@@ -688,7 +704,6 @@ func buildTextAtom(name, value string) []byte {
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, valueBytes...)
// container atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -703,7 +718,6 @@ func buildTextAtom(name, value string) []byte {
// buildTrackNumberAtom builds trkn atom
func buildTrackNumberAtom(track, total int) []byte {
// data atom with track number
dataAtom := []byte{
0, 0, 0, 24, // size
'd', 'a', 't', 'a',
@@ -715,7 +729,6 @@ func buildTrackNumberAtom(track, total int) []byte {
0, 0, // padding
}
// trkn atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -730,7 +743,6 @@ func buildTrackNumberAtom(track, total int) []byte {
// buildDiscNumberAtom builds disk atom
func buildDiscNumberAtom(disc, total int) []byte {
// data atom with disc number
dataAtom := []byte{
0, 0, 0, 22, // size
'd', 'a', 't', 'a',
@@ -741,7 +753,6 @@ func buildDiscNumberAtom(disc, total int) []byte {
byte(total >> 8), byte(total), // total discs
}
// disk atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -756,13 +767,11 @@ func buildDiscNumberAtom(disc, total int) []byte {
// buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte {
// Detect image type (JPEG = 13, PNG = 14)
imageType := byte(13) // default JPEG
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14 // PNG
}
// data atom
dataSize := 16 + len(coverData)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
@@ -774,7 +783,6 @@ func buildCoverAtom(coverData []byte) []byte {
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, coverData...)
// covr atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -794,24 +802,18 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
}
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
moovPos := findAtom(data, "moov", 0)
if moovPos < 0 {
return AudioQuality{}, fmt.Errorf("moov atom not found")
}
// Search for mp4a or alac atom which contains audio info
// This is a simplified search - real implementation would traverse the atom tree
for i := moovPos; i < len(data)-20; i++ {
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
if i+24 < len(data) {
sampleRate := int(data[i+22])<<8 | int(data[i+23])
// For AAC, bit depth is typically 16
bitDepth := 16
if string(data[i:i+4]) == "alac" {
// ALAC can have higher bit depth, check esds or alac specific data
bitDepth = 24 // Assume 24-bit for ALAC
bitDepth = 24
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
+7 -12
View File
@@ -35,7 +35,7 @@ func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, // Cache for 30 minutes
ttl: 30 * time.Minute,
}
})
return globalTrackIDCache
@@ -124,6 +124,7 @@ type ParallelDownloadResult struct {
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
// durationMs: track duration in milliseconds for lyrics matching
func FetchCoverAndLyricsParallel(
coverURL string,
maxQualityCover bool,
@@ -131,11 +132,11 @@ func FetchCoverAndLyricsParallel(
trackName string,
artistName string,
embedLyrics bool,
durationMs int64,
) *ParallelDownloadResult {
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
// Download cover in parallel
if coverURL != "" {
wg.Add(1)
go func() {
@@ -159,13 +160,13 @@ func FetchCoverAndLyricsParallel(
defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics
// Use LRC with metadata headers (like PC version)
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
@@ -202,12 +203,10 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache()
// Limit concurrent pre-warm requests
semaphore := make(chan struct{}, 3) // Max 3 concurrent
semaphore := make(chan struct{}, 3)
var wg sync.WaitGroup
for _, req := range requests {
// Skip if already cached
if cached := cache.Get(req.ISRC); cached != nil {
continue
}
@@ -252,11 +251,9 @@ func preWarmQobuzCache(isrc string) {
}
func preWarmAmazonCache(isrc, spotifyID string) {
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
// Store Amazon URL in cache (using ISRC as key)
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
}
@@ -270,10 +267,8 @@ func preWarmAmazonCache(isrc, spotifyID string) {
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
// Parse JSON (simplified - in production use proper JSON parsing)
// For now, this is called from exports.go with proper parsing
go PreWarmTrackCache(requests) // Run in background
go PreWarmTrackCache(requests)
return nil
}
+1 -6
View File
@@ -44,16 +44,14 @@ var (
)
// getProgress returns current download progress from multi-progress system
// Returns first active item's progress for backward compatibility
func getProgress() DownloadProgress {
multiMu.RLock()
defer multiMu.RUnlock()
// Find first active item
for _, item := range multiProgress.Items {
return DownloadProgress{
CurrentFile: item.ItemID,
Progress: item.Progress * 100, // Convert to percentage
Progress: item.Progress * 100,
BytesTotal: item.BytesTotal,
BytesReceived: item.BytesReceived,
IsDownloading: item.IsDownloading,
@@ -249,10 +247,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
}
pw.current += int64(n)
// Update progress when we've received at least 64KB since last update
// Also update on first write to show download has started
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
// Calculate speed (MB/s) based on bytes received since last update
now := time.Now()
elapsed := now.Sub(pw.lastTime).Seconds()
var speedMBps float64
+5 -28
View File
@@ -1,8 +1,8 @@
package gobackend
import (
"context"
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
@@ -25,7 +25,6 @@ type QobuzDownloader struct {
}
var (
// Global Qobuz downloader instance for connection reuse
globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once
)
@@ -66,22 +65,17 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return true
}
// Split expected artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
expectedArtists := qobuzSplitArtists(normExpected)
foundArtists := qobuzSplitArtists(normFound)
// Check if ANY expected artist matches ANY found artist
for _, exp := range expectedArtists {
for _, fnd := range foundArtists {
if exp == fnd {
return true
}
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
// Check same words different order
if qobuzSameWordsUnordered(exp, fnd) {
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
@@ -89,8 +83,6 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
}
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := qobuzIsLatinScript(expectedArtist)
foundLatin := qobuzIsLatinScript(foundArtist)
if expectedLatin != foundLatin {
@@ -855,7 +847,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("no Qobuz API available")
}
// Use parallel approach - request from all APIs simultaneously
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
if err != nil {
return "", err
@@ -899,7 +890,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -909,16 +899,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return err
}
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body)
}
@@ -926,7 +913,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
@@ -970,18 +956,15 @@ type QobuzDownloadResult struct {
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *QobuzTrack
var err error
// STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority)
if req.QobuzID != "" {
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
var trackID int64
@@ -1052,7 +1035,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
// Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -1064,7 +1046,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
@@ -1083,12 +1064,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
// Get actual quality from track metadata
actualBitDepth := track.MaximumBitDepth
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
if err != nil {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
@@ -1106,6 +1085,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
@@ -1120,16 +1100,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Embed metadata using parallel-fetched cover data
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
albumName := track.Album.Title
if req.AlbumName != "" {
albumName = req.AlbumName
@@ -1145,9 +1120,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
ISRC: track.ISRC,
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
}
// Use cover data from parallel fetch
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
-6
View File
@@ -30,31 +30,25 @@ func (r *RateLimiter) WaitForSlot() {
now := time.Now()
// Remove timestamps outside the window
r.cleanOldTimestamps(now)
// If under limit, record and return immediately
if len(r.timestamps) < r.maxRequests {
r.timestamps = append(r.timestamps, now)
return
}
// Calculate wait time until oldest timestamp expires
oldestTimestamp := r.timestamps[0]
waitUntil := oldestTimestamp.Add(r.window)
waitDuration := waitUntil.Sub(now)
if waitDuration > 0 {
// Release lock while waiting
r.mu.Unlock()
time.Sleep(waitDuration)
r.mu.Lock()
// Clean again after waiting
r.cleanOldTimestamps(time.Now())
}
// Record this request
r.timestamps = append(r.timestamps, time.Now())
}
+1 -30
View File
@@ -31,7 +31,6 @@ type TrackAvailability struct {
}
var (
// Global SongLink client instance for connection reuse
globalSongLinkClient *SongLinkClient
songLinkClientOnce sync.Once
)
@@ -40,7 +39,7 @@ var (
func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
client: NewHTTPClientWithTimeout(SongLinkTimeout),
}
})
return globalSongLinkClient
@@ -48,15 +47,12 @@ func NewSongLinkClient() *SongLinkClient {
// CheckTrackAvailability checks track availability on streaming platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Validate Spotify ID format (should be 22 characters alphanumeric)
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
// Use global rate limiter - blocks until request is allowed
songLinkRateLimiter.WaitForSlot()
// Build API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -68,7 +64,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Use retry logic with User-Agent
retryConfig := DefaultRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
@@ -76,7 +71,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
}
@@ -109,27 +103,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
SpotifyID: spotifyTrackID,
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
@@ -191,12 +180,9 @@ func checkQobuzAvailability(isrc string) bool {
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
func extractDeezerIDFromURL(deezerURL string) string {
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
parts := strings.Split(deezerURL, "/")
if len(parts) > 0 {
// Get the last part which should be the ID
lastPart := parts[len(parts)-1]
// Remove any query parameters
if idx := strings.Index(lastPart, "?"); idx > 0 {
lastPart = lastPart[:idx]
}
@@ -274,7 +260,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
SpotifyID: spotifyAlbumID,
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
@@ -309,13 +294,10 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return nil, fmt.Errorf("deezer track ID is empty")
}
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
// Build Deezer URL
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
// Build API URL using Deezer URL as source
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
@@ -371,25 +353,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
DeezerID: deezerTrackID,
}
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
// Extract Spotify ID from URL
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer URL
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.DeezerURL = deezerLink.URL
}
@@ -459,24 +436,20 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability := &TrackAvailability{}
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
@@ -488,10 +461,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
// extractSpotifyIDFromURL extracts Spotify track ID from URL
func extractSpotifyIDFromURL(spotifyURL string) string {
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
parts := strings.Split(spotifyURL, "/track/")
if len(parts) > 1 {
// Get the ID part and remove any query parameters
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
+4 -15
View File
@@ -84,12 +84,10 @@ func HasSpotifyCredentials() bool {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" {
return true
}
// Check environment variables
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
return true
}
@@ -102,12 +100,10 @@ func getCredentials() (string, string, error) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret, nil
}
// Check environment variables
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
@@ -115,14 +111,12 @@ func getCredentials() (string, string, error) {
return clientID, clientSecret, nil
}
// No credentials available
return "", "", ErrNoSpotifyCredentials
}
// NewSpotifyMetadataClient creates a new Spotify client
// Returns error if credentials are not configured
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
// Get credentials - will error if not configured
clientID, clientSecret, err := getCredentials()
if err != nil {
return nil, err
@@ -131,7 +125,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
@@ -188,6 +182,9 @@ type AlbumInfoMetadata struct {
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
Images string `json:"images"`
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated
Label string `json:"label,omitempty"` // Record label name
Copyright string `json:"copyright,omitempty"` // Copyright information
}
// AlbumResponsePayload is the response for album requests
@@ -393,10 +390,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
// SearchAll searches for tracks and artists on Spotify
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
// Create cache key
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
@@ -456,7 +451,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
})
}
// Limit artists to artistLimit
artistCount := len(response.Artists.Items)
if artistCount > artistLimit {
artistCount = artistLimit
@@ -473,7 +467,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
})
}
// Store in cache
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
@@ -510,7 +503,6 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
}
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
@@ -610,7 +602,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
TrackList: tracks,
}
// Store in cache
c.cacheMu.Lock()
c.albumCache[albumID] = &cacheEntry{
data: result,
@@ -768,7 +759,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
@@ -856,7 +846,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Albums: albums,
}
// Store in cache
c.cacheMu.Lock()
c.artistCache[artistID] = &cacheEntry{
data: result,
+13 -90
View File
@@ -1,8 +1,8 @@
package gobackend
import (
"context"
"bufio"
"context"
"encoding/base64"
"encoding/json"
"encoding/xml"
@@ -31,7 +31,6 @@ type TidalDownloader struct {
}
var (
// Global Tidal downloader instance for token reuse
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
)
@@ -118,7 +117,6 @@ func NewTidalDownloader() *TidalDownloader {
clientSecret: string(clientSecret),
}
// Get first available API
apis := globalTidalDownloader.GetAvailableAPIs()
if len(apis) > 0 {
globalTidalDownloader.apiURL = apis[0]
@@ -130,16 +128,14 @@ func NewTidalDownloader() *TidalDownloader {
// GetAvailableAPIs returns list of available Tidal APIs
func (t *TidalDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{
// Priority 1: APIs that return FULL tracks (not PREVIEW)
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - returns FULL
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
// Priority 2: qqdl.site APIs (often return PREVIEW only)
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
"dGlkYWwua2lub3BsdXMub25saW5l",
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
"dHJpdG9uLnNxdWlkLnd0Zg==",
"dm9nZWwucXFkbC5zaXRl",
"bWF1cy5xcWRsLnNpdGU=",
"aHVuZC5xcWRsLnNpdGU=",
"a2F0emUucXFkbC5zaXRl",
"d29sZi5xcWRsLnNpdGU=",
}
var apis []string
@@ -159,7 +155,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
t.tokenMu.Lock()
defer t.tokenMu.Unlock()
// Return cached token if still valid (with 60s buffer)
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
return t.cachedToken, nil
}
@@ -194,7 +189,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
return "", err
}
// Cache the token
t.cachedToken = result.AccessToken
if result.ExpiresIn > 0 {
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
@@ -386,22 +380,17 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
queries = append(queries, artistName+" "+trackName)
}
// Strategy 2: Track name only
if trackName != "" {
queries = append(queries, trackName)
}
// Strategy 3: Romaji versions if Japanese detected (NEW - from PC version)
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
// Convert to romaji (hiragana/katakana only, kanji stays)
romajiTrack := JapaneseToRomaji(trackName)
romajiArtist := JapaneseToRomaji(artistName)
// Clean and remove ALL non-ASCII characters (including kanji)
cleanRomajiTrack := CleanToASCII(romajiTrack)
cleanRomajiArtist := CleanToASCII(romajiArtist)
// Artist + Track romaji (cleaned to ASCII only)
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
if !containsQuery(queries, romajiQuery) {
@@ -410,14 +399,12 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
}
// Track romaji only (cleaned)
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
if !containsQuery(queries, cleanRomajiTrack) {
queries = append(queries, cleanRomajiTrack)
}
}
// Also try with partial romaji (artist + cleaned track)
if artistName != "" && cleanRomajiTrack != "" {
partialQuery := artistName + " " + cleanRomajiTrack
if !containsQuery(queries, partialQuery) {
@@ -426,7 +413,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
}
// Strategy 4: Artist only as last resort
if artistName != "" {
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
if artistOnly != "" && !containsQuery(queries, artistOnly) {
@@ -436,7 +422,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
// Collect all search results from all queries
var allTracks []TidalTrack
searchedQueries := make(map[string]bool)
@@ -486,7 +471,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
for i := range result.Items {
if result.Items[i].ISRC == spotifyISRC {
track := &result.Items[i]
// Verify duration if provided
if expectedDuration > 0 {
durationDiff := track.Duration - expectedDuration
if durationDiff < 0 {
@@ -496,7 +480,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
return track, nil
}
// Duration mismatch, continue searching
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
expectedDuration, track.Duration)
} else {
@@ -515,7 +498,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
return nil, fmt.Errorf("no tracks found for any search query")
}
// Priority 1: Match by ISRC (exact match) WITH title verification
if spotifyISRC != "" {
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
var isrcMatches []*TidalTrack
@@ -527,7 +509,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
if len(isrcMatches) > 0 {
// Verify duration first (most important check)
if expectedDuration > 0 {
var durationVerifiedMatches []*TidalTrack
for _, track := range isrcMatches {
@@ -535,37 +516,31 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 3 seconds tolerance for duration (same as PC version)
if durationDiff <= 3 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
// Return first duration-verified match
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't - this is likely wrong version
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
expectedDuration, isrcMatches[0].Duration)
}
// No duration to verify, just return first ISRC match
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
// If ISRC was provided but no match found, return error
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
}
// Priority 2: Match by duration (within tolerance) + prefer best quality
if expectedDuration > 0 {
tolerance := 3 // 3 seconds tolerance
var durationMatches []*TidalTrack
@@ -582,7 +557,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
if len(durationMatches) > 0 {
// Find best quality among duration matches
bestMatch := durationMatches[0]
for _, track := range durationMatches {
for _, tag := range track.MediaMetadata.Tags {
@@ -598,7 +572,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
}
// Priority 3: Just take the best quality from first results
bestMatch := &allTracks[0]
for i := range allTracks {
track := &allTracks[i]
@@ -662,12 +635,10 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
resultChan := make(chan tidalAPIResult, len(apis))
startTime := time.Now()
// Start all requests in parallel
for _, apiURL := range apis {
go func(api string) {
reqStart := time.Now()
// Create client with timeout for parallel requests
client := &http.Client{
Timeout: 15 * time.Second,
}
@@ -698,7 +669,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
return
}
// Try v2 format first (object with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
@@ -716,7 +686,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
return
}
// Fallback to v1 format (array with OriginalTrackUrl)
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
@@ -738,13 +707,11 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
}(apiURL)
}
// Collect results - return first success
var errors []string
for i := 0; i < len(apis); i++ {
result := <-resultChan
if result.err == nil {
// First success - use this one
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
@@ -777,7 +744,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
}
// Use parallel approach - request from all APIs simultaneously
_, info, err := getDownloadURLParallel(apis, trackID, quality)
if err != nil {
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
@@ -795,16 +761,13 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
manifestStr := string(manifestBytes)
// Debug: log first 500 chars of manifest for debugging
manifestPreview := manifestStr
if len(manifestPreview) > 500 {
manifestPreview = manifestPreview[:500] + "..."
}
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
// Check if it's BTS format (JSON) or DASH format (XML)
if strings.HasPrefix(manifestStr, "{") {
// BTS format - JSON with direct URLs
var btsManifest TidalBTSManifest
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
@@ -817,7 +780,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return btsManifest.URLs[0], "", nil, nil
}
// DASH format - XML with segments
var mpd MPD
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
@@ -828,7 +790,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaTemplate := segTemplate.Media
if initURL == "" || mediaTemplate == "" {
// Fallback: try regex extraction
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
@@ -844,11 +805,9 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
}
// Unescape HTML entities in URLs
initURL = strings.ReplaceAll(initURL, "&amp;", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&")
// Calculate segment count from timeline
segmentCount := 0
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
for i, seg := range segTemplate.Timeline.Segments {
@@ -857,10 +816,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
}
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
// If no segments found via XML, try regex
if segmentCount == 0 {
fmt.Println("[Tidal] No segments from XML, trying regex...")
// Match <S d="..." /> or <S d="..." r="..." />
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
@@ -877,7 +834,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
}
// Generate media URLs for each segment
for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
@@ -890,9 +846,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
// Handle manifest-based download (DASH/BTS)
if strings.HasPrefix(downloadURL, "MANIFEST:") {
// Initialize progress tracking for manifest downloads
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
@@ -936,7 +890,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -946,24 +899,19 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return err
}
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body)
}
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
@@ -980,7 +928,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -1003,7 +950,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
Timeout: 120 * time.Second,
}
// If we have a direct URL (BTS format), download directly with progress tracking
if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
// Note: Progress tracking is initialized by the caller (DownloadFile)
@@ -1035,7 +981,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
expectedSize := resp.ContentLength
// Set total bytes for progress tracking
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -1045,7 +990,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return fmt.Errorf("failed to create file: %w", err)
}
// Use item progress writer
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
@@ -1068,7 +1012,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -1077,21 +1020,15 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return nil
}
// DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
// On Android, we can't use ffmpeg, so we save as M4A directly
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
// We just update progress here based on segment count
out, err := os.Create(m4aPath)
if err != nil {
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
return fmt.Errorf("failed to create M4A file: %w", err)
}
// Download initialization segment
GoLog("[Tidal] Downloading init segment...\n")
if isDownloadCancelled(itemID) {
out.Close()
@@ -1134,7 +1071,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return fmt.Errorf("failed to write init segment: %w", err)
}
// Download media segments with progress
totalSegments := len(mediaURLs)
for i, mediaURL := range mediaURLs {
if isDownloadCancelled(itemID) {
@@ -1147,7 +1083,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
}
// Update progress based on segment count
if itemID != "" {
progress := float64(i+1) / float64(totalSegments)
SetItemProgress(itemID, progress, 0, 0)
@@ -1514,7 +1449,6 @@ func isLatinScript(s string) bool {
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
@@ -1582,7 +1516,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
var tidalURL string
var slErr error
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
if strings.HasPrefix(req.SpotifyID, "deezer:") {
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
@@ -1593,12 +1526,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
if slErr == nil && tidalURL != "" {
// Extract track ID and get track info
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
if idErr == nil {
track, err = downloader.GetTrackInfoByID(trackID)
if track != nil {
// Get artist name from track
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
@@ -1608,7 +1539,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
tidalArtist = strings.Join(artistNames, ", ")
}
// Verify artist matches (SongLink is already accurate, no title check needed)
if !artistsMatch(req.ArtistName, tidalArtist) {
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
@@ -1680,12 +1610,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
// Cache the track ID for future use
if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
}
// Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -1697,7 +1625,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists (both FLAC and M4A)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
@@ -1713,14 +1640,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
os.Remove(tmpPath)
}
// Determine quality to use (default to LOSSLESS if not specified)
quality := req.Quality
if quality == "" {
quality = "LOSSLESS"
}
GoLog("[Tidal] Using quality: %s\n", quality)
// Get download URL using parallel API requests
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
if err != nil {
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
@@ -1741,6 +1666,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
@@ -1765,18 +1691,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Check if file was saved as M4A (DASH stream) instead of FLAC
// downloadFromManifest saves DASH streams as .m4a (m4aPath already defined above)
actualOutputPath := outputPath
if _, err := os.Stat(m4aPath); err == nil {
// File was saved as M4A, use that path
actualOutputPath = m4aPath
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
} else if _, err := os.Stat(outputPath); err != nil {
@@ -1795,9 +1716,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TotalTracks: req.TotalTracks,
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal
ISRC: track.ISRC, // Use actual ISRC from Tidal
Genre: req.Genre, // From Deezer album metadata
Label: req.Label, // From Deezer album metadata
Copyright: req.Copyright, // From Deezer album metadata
}
// Use cover data from parallel fetch
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
+19 -2
View File
@@ -161,7 +161,8 @@ import Gobackend // Import Go framework
let spotifyId = args["spotify_id"] as! String
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error)
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
if let error = error { throw error }
return response
@@ -171,7 +172,8 @@ import Gobackend // Import Go framework
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let filePath = args["file_path"] as? String ?? ""
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error }
return response
@@ -225,6 +227,13 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getDeezerExtendedMetadata":
let args = call.arguments as! [String: Any]
let trackId = args["track_id"] as! String
let response = GobackendGetDeezerExtendedMetadata(trackId, &error)
if let error = error { throw error }
return response
case "convertSpotifyToDeezer":
let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String
@@ -373,6 +382,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
case "invokeExtensionAction":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let actionName = args["action"] as! String
let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error)
if let error = error { throw error }
return response
case "searchTracksWithExtensions":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
+1 -4
View File
@@ -9,7 +9,6 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
return GoRouter(
@@ -35,7 +34,6 @@ class SpotiFLACApp extends ConsumerWidget {
final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
// Convert locale string to Locale object
Locale? locale;
if (localeString != 'system') {
locale = Locale(localeString);
@@ -52,8 +50,7 @@ class SpotiFLACApp extends ConsumerWidget {
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
routerConfig: router,
// Localization
locale: locale, // null = follow system
locale: locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
+2 -2
View File
@@ -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.1.0';
static const String buildNumber = '59';
static const String version = '3.1.2';
static const String buildNumber = '61';
static const String fullVersion = '$version+$buildNumber';
+60
View File
@@ -107,6 +107,7 @@ abstract class AppLocalizations {
Locale('de'),
Locale('en'),
Locale('es'),
Locale('es', 'ES'),
Locale('fr'),
Locale('hi'),
Locale('id'),
@@ -114,6 +115,7 @@ abstract class AppLocalizations {
Locale('ko'),
Locale('nl'),
Locale('pt'),
Locale('pt', 'PT'),
Locale('ru'),
Locale('zh'),
Locale('zh', 'CN'),
@@ -816,6 +818,12 @@ abstract class AppLocalizations {
/// **'The talented artist who created our beautiful app logo!'**
String get aboutLogoArtist;
/// Section for translators
///
/// In en, this message translates to:
/// **'Translators'**
String get aboutTranslators;
/// Section for special thanks
///
/// In en, this message translates to:
@@ -3252,6 +3260,36 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle;
/// Quality option - MP3 lossy format
///
/// In en, this message translates to:
/// **'MP3'**
String get qualityMp3;
/// Technical spec for MP3
///
/// In en, this message translates to:
/// **'320kbps (converted from FLAC)'**
String get qualityMp3Subtitle;
/// Setting - enable MP3 quality option
///
/// In en, this message translates to:
/// **'Enable MP3 Option'**
String get enableMp3Option;
/// Subtitle when MP3 is enabled
///
/// In en, this message translates to:
/// **'MP3 quality option is available'**
String get enableMp3OptionSubtitleOn;
/// Subtitle when MP3 is disabled
///
/// In en, this message translates to:
/// **'Downloads FLAC then converts to 320kbps MP3'**
String get enableMp3OptionSubtitleOff;
/// Note about quality availability
///
/// In en, this message translates to:
@@ -3588,6 +3626,12 @@ abstract class AppLocalizations {
/// **'Select tracks to delete'**
String get downloadedAlbumSelectToDelete;
/// Header for disc separator in multi-disc albums
///
/// In en, this message translates to:
/// **'Disc {discNumber}'**
String downloadedAlbumDiscHeader(int discNumber);
/// Extension capability - utility functions
///
/// In en, this message translates to:
@@ -3663,6 +3707,22 @@ class _AppLocalizationsDelegate
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when language+country codes are specified.
switch (locale.languageCode) {
case 'es':
{
switch (locale.countryCode) {
case 'ES':
return AppLocalizationsEsEs();
}
break;
}
case 'pt':
{
switch (locale.countryCode) {
case 'PT':
return AppLocalizationsPtPt();
}
break;
}
case 'zh':
{
switch (locale.countryCode) {
+160 -123
View File
@@ -13,56 +13,57 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get appDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Laden Sie Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
@override
String get navHome => 'Home';
String get navHome => 'Startseite';
@override
String get navHistory => 'History';
String get navHistory => 'Verlauf';
@override
String get navSettings => 'Settings';
String get navSettings => 'Einstellungen';
@override
String get navStore => 'Store';
@override
String get homeTitle => 'Home';
String get homeTitle => 'Startseite';
@override
String get homeSearchHint => 'Paste Spotify URL or search...';
String get homeSearchHint => 'Spotify-URL einfügen oder suchen...';
@override
String homeSearchHintExtension(String extensionName) {
return 'Search with $extensionName...';
return 'Mit $extensionName suchen...';
}
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
String get homeSupports =>
'Unterstützt: Titel, Album, Playlist, Künstler-URLs';
@override
String get homeRecent => 'Recent';
String get homeRecent => 'Zuletzt';
@override
String get historyTitle => 'History';
String get historyTitle => 'Verlauf';
@override
String historyDownloading(int count) {
return 'Downloading ($count)';
return 'Wird heruntergeladen ($count)';
}
@override
String get historyDownloaded => 'Downloaded';
String get historyDownloaded => 'Heruntergeladen';
@override
String get historyFilterAll => 'All';
String get historyFilterAll => 'Alle';
@override
String get historyFilterAlbums => 'Albums';
String get historyFilterAlbums => 'Alben';
@override
String get historyFilterSingles => 'Singles';
@@ -72,8 +73,8 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
other: '$count Titel',
one: '1 Titel',
);
return '$_temp0';
}
@@ -83,93 +84,95 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
other: '$count Alben',
one: '1 Album',
);
return '$_temp0';
}
@override
String get historyNoDownloads => 'No download history';
String get historyNoDownloads => 'Kein Download-Verlauf';
@override
String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
String get historyNoDownloadsSubtitle =>
'Heruntergeladene Titel werden hier angezeigt';
@override
String get historyNoAlbums => 'No album downloads';
String get historyNoAlbums => 'Keine Album-Downloads';
@override
String get historyNoAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
'Laden Sie mehrere Titel eines Albums herunter, um sie hier zu sehen';
@override
String get historyNoSingles => 'No single downloads';
String get historyNoSingles => 'Keine Einzel-Downloads';
@override
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
'Einzelne Titel-Downloads werden hier angezeigt';
@override
String get settingsTitle => 'Settings';
String get settingsTitle => 'Einstellungen';
@override
String get settingsDownload => 'Download';
String get settingsDownload => 'Herunterladen';
@override
String get settingsAppearance => 'Appearance';
String get settingsAppearance => 'Erscheinungsbild';
@override
String get settingsOptions => 'Options';
String get settingsOptions => 'Optionen';
@override
String get settingsExtensions => 'Extensions';
String get settingsExtensions => 'Erweiterungen';
@override
String get settingsAbout => 'About';
String get settingsAbout => 'Über';
@override
String get downloadTitle => 'Download';
String get downloadTitle => 'Herunterladen';
@override
String get downloadLocation => 'Download Location';
String get downloadLocation => 'Download-Speicherort';
@override
String get downloadLocationSubtitle => 'Choose where to save files';
String get downloadLocationSubtitle =>
'Wählen Sie den Speicherort für Dateien';
@override
String get downloadLocationDefault => 'Default location';
String get downloadLocationDefault => 'Standard-Speicherort';
@override
String get downloadDefaultService => 'Default Service';
String get downloadDefaultService => 'Standard-Dienst';
@override
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
String get downloadDefaultServiceSubtitle => 'Dienst für Downloads';
@override
String get downloadDefaultQuality => 'Default Quality';
String get downloadDefaultQuality => 'Standard-Qualität';
@override
String get downloadAskQuality => 'Ask Quality Before Download';
String get downloadAskQuality => 'Qualität vor Download abfragen';
@override
String get downloadAskQualitySubtitle =>
'Show quality picker for each download';
'Qualitätsauswahl für jeden Download anzeigen';
@override
String get downloadFilenameFormat => 'Filename Format';
String get downloadFilenameFormat => 'Dateinamenformat';
@override
String get downloadFolderOrganization => 'Folder Organization';
String get downloadFolderOrganization => 'Ordnerstruktur';
@override
String get downloadSeparateSingles => 'Separate Singles';
String get downloadSeparateSingles => 'Singles trennen';
@override
String get downloadSeparateSinglesSubtitle =>
'Put single tracks in a separate folder';
'Einzelne Titel in separatem Ordner speichern';
@override
String get qualityBest => 'Best Available';
String get qualityBest => 'Beste Qualität';
@override
String get qualityFlac => 'FLAC';
@@ -181,179 +184,186 @@ class AppLocalizationsDe extends AppLocalizations {
String get quality128 => '128 kbps';
@override
String get appearanceTitle => 'Appearance';
String get appearanceTitle => 'Erscheinungsbild';
@override
String get appearanceTheme => 'Theme';
String get appearanceTheme => 'Design';
@override
String get appearanceThemeSystem => 'System';
@override
String get appearanceThemeLight => 'Light';
String get appearanceThemeLight => 'Hell';
@override
String get appearanceThemeDark => 'Dark';
String get appearanceThemeDark => 'Dunkel';
@override
String get appearanceDynamicColor => 'Dynamic Color';
String get appearanceDynamicColor => 'Dynamische Farben';
@override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
String get appearanceDynamicColorSubtitle =>
'Farben von Ihrem Hintergrundbild verwenden';
@override
String get appearanceAccentColor => 'Accent Color';
String get appearanceAccentColor => 'Akzentfarbe';
@override
String get appearanceHistoryView => 'History View';
String get appearanceHistoryView => 'Verlaufsansicht';
@override
String get appearanceHistoryViewList => 'List';
String get appearanceHistoryViewList => 'Liste';
@override
String get appearanceHistoryViewGrid => 'Grid';
String get appearanceHistoryViewGrid => 'Raster';
@override
String get optionsTitle => 'Options';
String get optionsTitle => 'Optionen';
@override
String get optionsSearchSource => 'Search Source';
String get optionsSearchSource => 'Suchquelle';
@override
String get optionsPrimaryProvider => 'Primary Provider';
String get optionsPrimaryProvider => 'Primärer Anbieter';
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Dienst für die Suche nach Titelnamen.';
@override
String optionsUsingExtension(String extensionName) {
return 'Using extension: $extensionName';
return 'Erweiterung verwenden: $extensionName';
}
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Tippen Sie auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
@override
String get optionsAutoFallback => 'Auto Fallback';
String get optionsAutoFallback => 'Automatischer Fallback';
@override
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
'Andere Dienste versuchen, wenn Download fehlschlägt';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn =>
'Erweiterungen werden zuerst versucht';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff =>
'Nur integrierte Anbieter verwenden';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
String get optionsEmbedLyrics => 'Liedtexte einbetten';
@override
String get optionsEmbedLyricsSubtitle =>
'Embed synced lyrics into FLAC files';
'Synchronisierte Liedtexte in FLAC-Dateien einbetten';
@override
String get optionsMaxQualityCover => 'Max Quality Cover';
String get optionsMaxQualityCover => 'Maximale Cover-Qualität';
@override
String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art';
'Cover in höchster Auflösung herunterladen';
@override
String get optionsConcurrentDownloads => 'Concurrent Downloads';
String get optionsConcurrentDownloads => 'Parallele Downloads';
@override
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
String get optionsConcurrentSequential => 'Sequentiell (1 gleichzeitig)';
@override
String optionsConcurrentParallel(int count) {
return '$count parallel downloads';
return '$count parallele Downloads';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting';
'Parallele Downloads können Ratenlimitierung auslösen';
@override
String get optionsExtensionStore => 'Extension Store';
String get optionsExtensionStore => 'Erweiterungs-Store';
@override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
String get optionsExtensionStoreSubtitle =>
'Store-Tab in Navigation anzeigen';
@override
String get optionsCheckUpdates => 'Check for Updates';
String get optionsCheckUpdates => 'Nach Updates suchen';
@override
String get optionsCheckUpdatesSubtitle =>
'Notify when new version is available';
'Benachrichtigen, wenn neue Version verfügbar';
@override
String get optionsUpdateChannel => 'Update Channel';
String get optionsUpdateChannel => 'Update-Kanal';
@override
String get optionsUpdateChannelStable => 'Stable releases only';
String get optionsUpdateChannelStable => 'Nur stabile Versionen';
@override
String get optionsUpdateChannelPreview => 'Get preview releases';
String get optionsUpdateChannelPreview => 'Vorschau-Versionen erhalten';
@override
String get optionsUpdateChannelWarning =>
'Preview may contain bugs or incomplete features';
'Vorschau kann Fehler oder unvollständige Funktionen enthalten';
@override
String get optionsClearHistory => 'Clear Download History';
String get optionsClearHistory => 'Download-Verlauf löschen';
@override
String get optionsClearHistorySubtitle =>
'Remove all downloaded tracks from history';
'Alle heruntergeladenen Titel aus dem Verlauf entfernen';
@override
String get optionsDetailedLogging => 'Detailed Logging';
String get optionsDetailedLogging => 'Detaillierte Protokollierung';
@override
String get optionsDetailedLoggingOn => 'Detailed logs are being recorded';
String get optionsDetailedLoggingOn =>
'Detaillierte Protokolle werden aufgezeichnet';
@override
String get optionsDetailedLoggingOff => 'Enable for bug reports';
String get optionsDetailedLoggingOff => 'Für Fehlerberichte aktivieren';
@override
String get optionsSpotifyCredentials => 'Spotify Credentials';
String get optionsSpotifyCredentials => 'Spotify-Anmeldedaten';
@override
String optionsSpotifyCredentialsConfigured(String clientId) {
return 'Client ID: $clientId...';
return 'Client-ID: $clientId...';
}
@override
String get optionsSpotifyCredentialsRequired => 'Required - tap to configure';
String get optionsSpotifyCredentialsRequired =>
'Erforderlich - zum Konfigurieren tippen';
@override
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com';
@override
String get extensionsTitle => 'Extensions';
String get extensionsTitle => 'Erweiterungen';
@override
String get extensionsInstalled => 'Installed Extensions';
String get extensionsInstalled => 'Installierte Erweiterungen';
@override
String get extensionsNone => 'No extensions installed';
String get extensionsNone => 'Keine Erweiterungen installiert';
@override
String get extensionsNoneSubtitle => 'Install extensions from the Store tab';
String get extensionsNoneSubtitle =>
'Erweiterungen aus dem Store-Tab installieren';
@override
String get extensionsEnabled => 'Enabled';
String get extensionsEnabled => 'Aktiviert';
@override
String get extensionsDisabled => 'Disabled';
String get extensionsDisabled => 'Deaktiviert';
@override
String extensionsVersion(String version) {
@@ -362,78 +372,84 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String extensionsAuthor(String author) {
return 'by $author';
return 'von $author';
}
@override
String get extensionsUninstall => 'Uninstall';
String get extensionsUninstall => 'Deinstallieren';
@override
String get extensionsSetAsSearch => 'Set as Search Provider';
String get extensionsSetAsSearch => 'Als Suchanbieter festlegen';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => 'Erweiterungs-Store';
@override
String get storeSearch => 'Search extensions...';
String get storeSearch => 'Erweiterungen suchen...';
@override
String get storeInstall => 'Install';
String get storeInstall => 'Installieren';
@override
String get storeInstalled => 'Installed';
String get storeInstalled => 'Installiert';
@override
String get storeUpdate => 'Update';
String get storeUpdate => 'Aktualisieren';
@override
String get aboutTitle => 'About';
String get aboutTitle => 'Über';
@override
String get aboutContributors => 'Contributors';
String get aboutContributors => 'Mitwirkende';
@override
String get aboutMobileDeveloper => 'Mobile version developer';
String get aboutMobileDeveloper => 'Mobile-Version Entwickler';
@override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
String get aboutOriginalCreator => 'Schöpfer des ursprünglichen SpotiFLAC';
@override
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
'Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!';
@override
String get aboutSpecialThanks => 'Special Thanks';
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Besonderer Dank';
@override
String get aboutLinks => 'Links';
@override
String get aboutMobileSource => 'Mobile source code';
String get aboutMobileSource => 'Mobiler Quellcode';
@override
String get aboutPCSource => 'PC source code';
String get aboutPCSource => 'PC Quellcode';
@override
String get aboutReportIssue => 'Report an issue';
String get aboutReportIssue => 'Problem melden';
@override
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
String get aboutReportIssueSubtitle =>
'Melde jedes Problem, die dir auftreten';
@override
String get aboutFeatureRequest => 'Feature request';
String get aboutFeatureRequest => 'Feature vorschlagen';
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
String get aboutFeatureRequestSubtitle =>
'Schlage neue Funktionen für die App vor';
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
String get aboutBuyMeCoffeeSubtitle =>
'Unterstütze die Entwicklung auf Ko-fi';
@override
String get aboutApp => 'App';
@@ -443,25 +459,25 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!';
@override
String get aboutDoubleDouble => 'DoubleDouble';
@override
String get aboutDoubleDoubleDesc =>
'Amazing API for Amazon Music downloads. Thank you for making it free!';
'Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!';
@override
String get aboutDabMusic => 'DAB Music';
@override
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!';
@override
String get aboutAppDescription =>
@@ -1782,6 +1798,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +2005,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
File diff suppressed because it is too large Load Diff
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -406,6 +406,9 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutLogoArtist =>
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Terima Kasih Khusus';
@@ -1794,6 +1797,22 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (konversi dari FLAC)';
@override
String get enableMp3Option => 'Aktifkan Opsi MP3';
@override
String get enableMp3OptionSubtitleOn => 'Opsi kualitas MP3 tersedia';
@override
String get enableMp3OptionSubtitleOff =>
'Unduh FLAC lalu konversi ke MP3 320kbps';
@override
String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@@ -1986,6 +2005,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Fungsi Utilitas';
+151 -127
View File
@@ -16,19 +16,19 @@ class AppLocalizationsJa extends AppLocalizations {
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@override
String get navHome => 'Home';
String get navHome => 'ホーム';
@override
String get navHistory => 'History';
String get navHistory => '履歴';
@override
String get navSettings => 'Settings';
String get navSettings => '設定';
@override
String get navStore => 'Store';
String get navStore => 'ストア';
@override
String get homeTitle => 'Home';
String get homeTitle => 'ホーム';
@override
String get homeSearchHint => 'Paste Spotify URL or search...';
@@ -52,20 +52,20 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String historyDownloading(int count) {
return 'Downloading ($count)';
return 'ダウンロード中 ($count)';
}
@override
String get historyDownloaded => 'Downloaded';
String get historyDownloaded => 'ダウンロード済み';
@override
String get historyFilterAll => 'All';
String get historyFilterAll => 'すべて';
@override
String get historyFilterAlbums => 'Albums';
String get historyFilterAlbums => 'アルバム';
@override
String get historyFilterSingles => 'Singles';
String get historyFilterSingles => 'シングル';
@override
String historyTracksCount(int count) {
@@ -110,25 +110,25 @@ class AppLocalizationsJa extends AppLocalizations {
'Single track downloads will appear here';
@override
String get settingsTitle => 'Settings';
String get settingsTitle => '設定';
@override
String get settingsDownload => 'Download';
String get settingsDownload => 'ダウンロード';
@override
String get settingsAppearance => 'Appearance';
String get settingsAppearance => '外観';
@override
String get settingsOptions => 'Options';
String get settingsOptions => 'オプション';
@override
String get settingsExtensions => 'Extensions';
String get settingsExtensions => '拡張';
@override
String get settingsAbout => 'About';
String get settingsAbout => 'アプリについて';
@override
String get downloadTitle => 'Download';
String get downloadTitle => 'ダウンロード';
@override
String get downloadLocation => 'Download Location';
@@ -137,16 +137,16 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadLocationSubtitle => 'Choose where to save files';
@override
String get downloadLocationDefault => 'Default location';
String get downloadLocationDefault => 'デフォルトの場所';
@override
String get downloadDefaultService => 'Default Service';
String get downloadDefaultService => 'デフォルトのサービス';
@override
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
String get downloadDefaultServiceSubtitle => 'ダウンロードに使用したサービス';
@override
String get downloadDefaultQuality => 'Default Quality';
String get downloadDefaultQuality => 'デフォルトの品質';
@override
String get downloadAskQuality => 'Ask Quality Before Download';
@@ -156,7 +156,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Show quality picker for each download';
@override
String get downloadFilenameFormat => 'Filename Format';
String get downloadFilenameFormat => 'ファイル名の形式';
@override
String get downloadFolderOrganization => 'Folder Organization';
@@ -181,46 +181,46 @@ class AppLocalizationsJa extends AppLocalizations {
String get quality128 => '128 kbps';
@override
String get appearanceTitle => 'Appearance';
String get appearanceTitle => '外観';
@override
String get appearanceTheme => 'Theme';
String get appearanceTheme => 'テーマ';
@override
String get appearanceThemeSystem => 'System';
String get appearanceThemeSystem => 'システム';
@override
String get appearanceThemeLight => 'Light';
String get appearanceThemeLight => 'ライト';
@override
String get appearanceThemeDark => 'Dark';
String get appearanceThemeDark => 'ダーク';
@override
String get appearanceDynamicColor => 'Dynamic Color';
String get appearanceDynamicColor => 'ダイナミックカラー';
@override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
@override
String get appearanceAccentColor => 'Accent Color';
String get appearanceAccentColor => 'アクセントカラー';
@override
String get appearanceHistoryView => 'History View';
String get appearanceHistoryView => '履歴の表示';
@override
String get appearanceHistoryViewList => 'List';
String get appearanceHistoryViewList => 'リスト';
@override
String get appearanceHistoryViewGrid => 'Grid';
String get appearanceHistoryViewGrid => 'グリッド';
@override
String get optionsTitle => 'Options';
String get optionsTitle => 'オプション';
@override
String get optionsSearchSource => 'Search Source';
String get optionsSearchSource => '検索ソース';
@override
String get optionsPrimaryProvider => 'Primary Provider';
String get optionsPrimaryProvider => 'プライマリーのプロバイダー';
@override
String get optionsPrimaryProviderSubtitle =>
@@ -228,7 +228,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String optionsUsingExtension(String extensionName) {
return 'Using extension: $extensionName';
return '拡張の使用: $extensionName';
}
@override
@@ -243,23 +243,23 @@ class AppLocalizationsJa extends AppLocalizations {
'Try other services if download fails';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff => '内蔵のプロバイダーのみを使用する';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
String get optionsEmbedLyrics => '歌詞を埋め込む';
@override
String get optionsEmbedLyricsSubtitle =>
'Embed synced lyrics into FLAC files';
@override
String get optionsMaxQualityCover => 'Max Quality Cover';
String get optionsMaxQualityCover => '最大品質のカバー';
@override
String get optionsMaxQualityCoverSubtitle =>
@@ -281,26 +281,26 @@ class AppLocalizationsJa extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Store';
String get optionsExtensionStore => '拡張ストア';
@override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
String get optionsCheckUpdates => '更新を確認';
@override
String get optionsCheckUpdatesSubtitle =>
'Notify when new version is available';
@override
String get optionsUpdateChannel => 'Update Channel';
String get optionsUpdateChannel => '更新チャンネル';
@override
String get optionsUpdateChannelStable => 'Stable releases only';
String get optionsUpdateChannelStable => '安定版リリースのみ';
@override
String get optionsUpdateChannelPreview => 'Get preview releases';
String get optionsUpdateChannelPreview => 'プレビューリリースを入手';
@override
String get optionsUpdateChannelWarning =>
@@ -323,11 +323,11 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsDetailedLoggingOff => 'Enable for bug reports';
@override
String get optionsSpotifyCredentials => 'Spotify Credentials';
String get optionsSpotifyCredentials => 'Spotify の認証情報';
@override
String optionsSpotifyCredentialsConfigured(String clientId) {
return 'Client ID: $clientId...';
return 'クライアント ID: $clientId...';
}
@override
@@ -338,62 +338,62 @@ class AppLocalizationsJa extends AppLocalizations {
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get extensionsTitle => 'Extensions';
String get extensionsTitle => '拡張';
@override
String get extensionsInstalled => 'Installed Extensions';
String get extensionsInstalled => 'インストール済みの拡張';
@override
String get extensionsNone => 'No extensions installed';
String get extensionsNone => '拡張はインストールされていません';
@override
String get extensionsNoneSubtitle => 'Install extensions from the Store tab';
String get extensionsNoneSubtitle => 'ストアタブから拡張をインストール';
@override
String get extensionsEnabled => 'Enabled';
String get extensionsEnabled => '有効';
@override
String get extensionsDisabled => 'Disabled';
@override
String extensionsVersion(String version) {
return 'Version $version';
return 'バージョン $version';
}
@override
String extensionsAuthor(String author) {
return 'by $author';
return '作者 $author';
}
@override
String get extensionsUninstall => 'Uninstall';
String get extensionsUninstall => 'アンインストール';
@override
String get extensionsSetAsSearch => 'Set as Search Provider';
String get extensionsSetAsSearch => '検索プロバイダーを設定';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => '拡張ストア';
@override
String get storeSearch => 'Search extensions...';
String get storeSearch => '拡張を検索...';
@override
String get storeInstall => 'Install';
String get storeInstall => 'インストール';
@override
String get storeInstalled => 'Installed';
String get storeInstalled => 'インストール済み';
@override
String get storeUpdate => 'Update';
String get storeUpdate => '更新';
@override
String get aboutTitle => 'About';
String get aboutTitle => 'アプリについて';
@override
String get aboutContributors => 'Contributors';
String get aboutContributors => '貢献者';
@override
String get aboutMobileDeveloper => 'Mobile version developer';
String get aboutMobileDeveloper => 'モバイルバージョンの開発者';
@override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
@@ -403,25 +403,28 @@ class AppLocalizationsJa extends AppLocalizations {
'The talented artist who created our beautiful app logo!';
@override
String get aboutSpecialThanks => 'Special Thanks';
String get aboutTranslators => 'Translators';
@override
String get aboutLinks => 'Links';
String get aboutSpecialThanks => 'スペシャルサンクス';
@override
String get aboutMobileSource => 'Mobile source code';
String get aboutLinks => 'リンク';
@override
String get aboutPCSource => 'PC source code';
String get aboutMobileSource => 'モバイル版のソースコード';
@override
String get aboutReportIssue => 'Report an issue';
String get aboutPCSource => 'PC 版のソースコード';
@override
String get aboutReportIssue => 'Issue で報告する';
@override
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
@override
String get aboutFeatureRequest => 'Feature request';
String get aboutFeatureRequest => '機能の要望';
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
@@ -430,16 +433,16 @@ class AppLocalizationsJa extends AppLocalizations {
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
@override
String get aboutApp => 'App';
String get aboutApp => 'アプリ';
@override
String get aboutVersion => 'Version';
String get aboutVersion => 'バージョン';
@override
String get aboutBinimumDesc =>
@@ -497,10 +500,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get artistAlbums => 'Albums';
@override
String get artistSingles => 'Singles & EPs';
String get artistSingles => 'シングルと EP';
@override
String get artistCompilations => 'Compilations';
String get artistCompilations => 'コンピレーション';
@override
String artistReleases(int count) {
@@ -589,13 +592,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupChooseFolder => 'Choose Folder';
@override
String get setupContinue => 'Continue';
String get setupContinue => '続行';
@override
String get setupSkip => 'Skip for now';
String get setupSkip => '今はスキップ';
@override
String get setupStorageAccessRequired => 'Storage Access Required';
String get setupStorageAccessRequired => 'ストレージアクセスが必要です';
@override
String get setupStorageAccessMessage =>
@@ -675,7 +678,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupStepSpotify => 'Spotify';
@override
String get setupStepPermission => 'Permission';
String get setupStepPermission => '権限';
@override
String get setupStorageGranted => 'Storage Permission Granted!';
@@ -691,14 +694,14 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupNotificationGranted => 'Notification Permission Granted!';
@override
String get setupNotificationEnable => 'Enable Notifications';
String get setupNotificationEnable => '通知を有効化する';
@override
String get setupNotificationDescription =>
'Get notified when downloads complete or require attention.';
@override
String get setupFolderSelected => 'Download Folder Selected!';
String get setupFolderSelected => 'ダウンロードフォルダが選択済みです!';
@override
String get setupFolderChoose => 'Choose Download Folder';
@@ -714,26 +717,26 @@ class AppLocalizationsJa extends AppLocalizations {
String get setupSelectFolder => 'Select Folder';
@override
String get setupSpotifyApiOptional => 'Spotify API (Optional)';
String get setupSpotifyApiOptional => 'Spotify API (任意)';
@override
String get setupSpotifyApiDescription =>
'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.';
@override
String get setupUseSpotifyApi => 'Use Spotify API';
String get setupUseSpotifyApi => 'Spotify API を使用する';
@override
String get setupEnterCredentialsBelow => 'Enter your credentials below';
@override
String get setupUsingDeezer => 'Using Deezer (no account needed)';
String get setupUsingDeezer => 'Deezer を使用中 (アカウントは不要です)';
@override
String get setupEnterClientId => 'Enter Spotify Client ID';
String get setupEnterClientId => 'Spotify クライアント ID を入力';
@override
String get setupEnterClientSecret => 'Enter Spotify Client Secret';
String get setupEnterClientSecret => 'Spotify クライアントシークレットを入力';
@override
String get setupGetFreeCredentials =>
@@ -754,19 +757,19 @@ class AppLocalizationsJa extends AppLocalizations {
'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
@override
String get setupSkipForNow => 'Skip for now';
String get setupSkipForNow => '今はスキップ';
@override
String get setupBack => 'Back';
String get setupBack => '戻る';
@override
String get setupNext => 'Next';
String get setupNext => '次へ';
@override
String get setupGetStarted => 'Get Started';
@override
String get setupSkipAndStart => 'Skip & Start';
String get setupSkipAndStart => 'スキップと開始';
@override
String get setupAllowAccessToManageFiles =>
@@ -858,7 +861,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Are you sure you want to remove this extension? This cannot be undone.';
@override
String get dialogUninstallExtension => 'Uninstall Extension?';
String get dialogUninstallExtension => '拡張をアンインストールしますか?';
@override
String dialogUninstallExtensionMessage(String extensionName) {
@@ -887,7 +890,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get dialogImportPlaylistTitle => 'Import Playlist';
String get dialogImportPlaylistTitle => 'プレイリストをインポート';
@override
String dialogImportPlaylistMessage(int count) {
@@ -980,7 +983,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get snackbarFailedToUpdate => 'Failed to update extension';
@override
String get errorRateLimited => 'Rate Limited';
String get errorRateLimited => 'レート制限';
@override
String get errorRateLimitedMessage =>
@@ -1178,7 +1181,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get updateDownload => 'Download';
String get updateDownload => 'ダウンロード';
@override
String get updateLater => 'Later';
@@ -1199,7 +1202,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get updateNewVersionReady => 'A new version is ready';
@override
String get updateCurrent => 'Current';
String get updateCurrent => '現在';
@override
String get updateNew => 'New';
@@ -1303,13 +1306,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get logClearLogsMessage => 'Are you sure you want to clear all logs?';
@override
String get logIspBlocking => 'ISP BLOCKING DETECTED';
String get logIspBlocking => 'ISP のブロックを検出しました';
@override
String get logRateLimited => 'RATE LIMITED';
String get logRateLimited => 'レート制限';
@override
String get logNetworkError => 'NETWORK ERROR';
String get logNetworkError => 'ネットワークエラー';
@override
String get logTrackNotFound => 'TRACK NOT FOUND';
@@ -1498,22 +1501,22 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackMetadata => 'Metadata';
@override
String get trackFileInfo => 'File Info';
String get trackFileInfo => 'ファイル情報';
@override
String get trackLyrics => 'Lyrics';
String get trackLyrics => '歌詞';
@override
String get trackFileNotFound => 'File not found';
String get trackFileNotFound => 'ファイルがありません';
@override
String get trackOpenInDeezer => 'Open in Deezer';
String get trackOpenInDeezer => 'Deezer で開く';
@override
String get trackOpenInSpotify => 'Open in Spotify';
String get trackOpenInSpotify => 'Spotify で開く';
@override
String get trackTrackName => 'Track name';
String get trackTrackName => 'トラック名';
@override
String get trackArtist => 'Artist';
@@ -1636,16 +1639,16 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
@override
String get extensionAuthor => 'Author';
String get extensionAuthor => '作者';
@override
String get extensionId => 'ID';
@override
String get extensionError => 'Error';
String get extensionError => 'エラー';
@override
String get extensionCapabilities => 'Capabilities';
@@ -1675,16 +1678,16 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionSettings => 'Settings';
@override
String get extensionRemoveButton => 'Remove Extension';
String get extensionRemoveButton => '拡張を削除';
@override
String get extensionUpdated => 'Updated';
String get extensionUpdated => '更新済み';
@override
String get extensionMinAppVersion => 'Min App Version';
String get extensionMinAppVersion => '最小のアプリバージョン';
@override
String get extensionCustomTrackMatching => 'Custom Track Matching';
String get extensionCustomTrackMatching => 'カスタムトラックマッチング';
@override
String get extensionPostProcessing => 'Post-Processing';
@@ -1708,17 +1711,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionsProviderPrioritySection => 'Provider Priority';
@override
String get extensionsInstalledSection => 'Installed Extensions';
String get extensionsInstalledSection => 'インストール済みの拡張';
@override
String get extensionsNoExtensions => 'No extensions installed';
String get extensionsNoExtensions => '拡張はインストールされていません';
@override
String get extensionsNoExtensionsSubtitle =>
'Install .spotiflac-ext files to add new providers';
@override
String get extensionsInstallButton => 'Install Extension';
String get extensionsInstallButton => '拡張をインストール';
@override
String get extensionsInfoTip =>
@@ -1765,22 +1768,38 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionsErrorLoading => 'Error loading extension';
@override
String get qualityFlacLossless => 'FLAC Lossless';
String get qualityFlacLossless => 'FLAC ロスレス';
@override
String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz';
@override
String get qualityHiResFlac => 'Hi-Res FLAC';
String get qualityHiResFlac => 'ハイレゾ FLAC';
@override
String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz';
String get qualityHiResFlacSubtitle => '24-bit / 最大 96kHz';
@override
String get qualityHiResFlacMax => 'Hi-Res FLAC Max';
String get qualityHiResFlacMax => 'ハイレゾ FLAC 最大';
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
@@ -1790,10 +1809,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadAskBeforeDownload => 'Ask Before Download';
@override
String get downloadDirectory => 'Download Directory';
String get downloadDirectory => 'ダウンロードディレクトリ';
@override
String get downloadSeparateSinglesFolder => 'Separate Singles Folder';
String get downloadSeparateSinglesFolder => 'シングルのフォルダを分割';
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@@ -1856,22 +1875,22 @@ class AppLocalizationsJa extends AppLocalizations {
String get serviceSpotify => 'Spotify';
@override
String get appearanceAmoledDark => 'AMOLED Dark';
String get appearanceAmoledDark => 'AMOLED ダーク';
@override
String get appearanceAmoledDarkSubtitle => 'Pure black background';
String get appearanceAmoledDarkSubtitle => 'ピュアブラックの背景';
@override
String get appearanceChooseAccentColor => 'Choose Accent Color';
@override
String get appearanceChooseTheme => 'Theme Mode';
String get appearanceChooseTheme => 'テーマモード';
@override
String get queueTitle => 'Download Queue';
String get queueTitle => 'ダウンロードキュー';
@override
String get queueClearAll => 'Clear All';
String get queueClearAll => 'すべて消去';
@override
String get queueClearAllMessage =>
@@ -1973,6 +1992,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+24
View File
@@ -402,6 +402,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutLogoArtist =>
'The talented artist who created our beautiful app logo!';
@override
String get aboutTranslators => 'Translators';
@override
String get aboutSpecialThanks => 'Special Thanks';
@@ -1782,6 +1785,22 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1992,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174 -136
View File
@@ -5,19 +5,19 @@
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"appDescription": "Laden Sie Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
"@appDescription": {
"description": "App description shown in about page"
},
"navHome": "Home",
"navHome": "Startseite",
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navHistory": "History",
"navHistory": "Verlauf",
"@navHistory": {
"description": "Bottom navigation - History tab"
},
"navSettings": "Settings",
"navSettings": "Einstellungen",
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
@@ -25,15 +25,15 @@
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
"homeTitle": "Home",
"homeTitle": "Startseite",
"@homeTitle": {
"description": "Home screen title"
},
"homeSearchHint": "Paste Spotify URL or search...",
"homeSearchHint": "Spotify-URL einfügen oder suchen...",
"@homeSearchHint": {
"description": "Placeholder text in search box"
},
"homeSearchHintExtension": "Search with {extensionName}...",
"homeSearchHintExtension": "Mit {extensionName} suchen...",
"@homeSearchHintExtension": {
"description": "Placeholder when extension search is active",
"placeholders": {
@@ -43,23 +43,23 @@
}
}
},
"homeSubtitle": "Paste a Spotify link or search by name",
"homeSubtitle": "Spotify-Link einfügen oder nach Namen suchen",
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
"homeSupports": "Unterstützt: Titel, Album, Playlist, Künstler-URLs",
"@homeSupports": {
"description": "Info text about supported URL types"
},
"homeRecent": "Recent",
"homeRecent": "Zuletzt",
"@homeRecent": {
"description": "Section header for recent searches"
},
"historyTitle": "History",
"historyTitle": "Verlauf",
"@historyTitle": {
"description": "History screen title"
},
"historyDownloading": "Downloading ({count})",
"historyDownloading": "Wird heruntergeladen ({count})",
"@historyDownloading": {
"description": "Tab showing active downloads count",
"placeholders": {
@@ -69,15 +69,15 @@
}
}
},
"historyDownloaded": "Downloaded",
"historyDownloaded": "Heruntergeladen",
"@historyDownloaded": {
"description": "Tab showing completed downloads"
},
"historyFilterAll": "All",
"historyFilterAll": "Alle",
"@historyFilterAll": {
"description": "Filter chip - show all items"
},
"historyFilterAlbums": "Albums",
"historyFilterAlbums": "Alben",
"@historyFilterAlbums": {
"description": "Filter chip - show albums only"
},
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, =1{1 track} other{{count} tracks}}",
"historyTracksCount": "{count, plural, =1{1 Titel} other{{count} Titel}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} albums}}",
"historyAlbumsCount": "{count, plural, =1{1 Album} other{{count} Alben}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -103,107 +103,107 @@
}
}
},
"historyNoDownloads": "No download history",
"historyNoDownloads": "Kein Download-Verlauf",
"@historyNoDownloads": {
"description": "Empty state title"
},
"historyNoDownloadsSubtitle": "Downloaded tracks will appear here",
"historyNoDownloadsSubtitle": "Heruntergeladene Titel werden hier angezeigt",
"@historyNoDownloadsSubtitle": {
"description": "Empty state subtitle"
},
"historyNoAlbums": "No album downloads",
"historyNoAlbums": "Keine Album-Downloads",
"@historyNoAlbums": {
"description": "Empty state when filtering albums"
},
"historyNoAlbumsSubtitle": "Download multiple tracks from an album to see them here",
"historyNoAlbumsSubtitle": "Laden Sie mehrere Titel eines Albums herunter, um sie hier zu sehen",
"@historyNoAlbumsSubtitle": {
"description": "Empty state subtitle for albums filter"
},
"historyNoSingles": "No single downloads",
"historyNoSingles": "Keine Einzel-Downloads",
"@historyNoSingles": {
"description": "Empty state when filtering singles"
},
"historyNoSinglesSubtitle": "Single track downloads will appear here",
"historyNoSinglesSubtitle": "Einzelne Titel-Downloads werden hier angezeigt",
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"settingsTitle": "Settings",
"settingsTitle": "Einstellungen",
"@settingsTitle": {
"description": "Settings screen title"
},
"settingsDownload": "Download",
"settingsDownload": "Herunterladen",
"@settingsDownload": {
"description": "Settings section - download options"
},
"settingsAppearance": "Appearance",
"settingsAppearance": "Erscheinungsbild",
"@settingsAppearance": {
"description": "Settings section - visual customization"
},
"settingsOptions": "Options",
"settingsOptions": "Optionen",
"@settingsOptions": {
"description": "Settings section - app options"
},
"settingsExtensions": "Extensions",
"settingsExtensions": "Erweiterungen",
"@settingsExtensions": {
"description": "Settings section - extension management"
},
"settingsAbout": "About",
"settingsAbout": "Über",
"@settingsAbout": {
"description": "Settings section - app info"
},
"downloadTitle": "Download",
"downloadTitle": "Herunterladen",
"@downloadTitle": {
"description": "Download settings page title"
},
"downloadLocation": "Download Location",
"downloadLocation": "Download-Speicherort",
"@downloadLocation": {
"description": "Setting for download folder"
},
"downloadLocationSubtitle": "Choose where to save files",
"downloadLocationSubtitle": "Wählen Sie den Speicherort für Dateien",
"@downloadLocationSubtitle": {
"description": "Subtitle for download location"
},
"downloadLocationDefault": "Default location",
"downloadLocationDefault": "Standard-Speicherort",
"@downloadLocationDefault": {
"description": "Shown when using default folder"
},
"downloadDefaultService": "Default Service",
"downloadDefaultService": "Standard-Dienst",
"@downloadDefaultService": {
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
},
"downloadDefaultServiceSubtitle": "Service used for downloads",
"downloadDefaultServiceSubtitle": "Dienst für Downloads",
"@downloadDefaultServiceSubtitle": {
"description": "Subtitle for default service"
},
"downloadDefaultQuality": "Default Quality",
"downloadDefaultQuality": "Standard-Qualität",
"@downloadDefaultQuality": {
"description": "Setting for audio quality"
},
"downloadAskQuality": "Ask Quality Before Download",
"downloadAskQuality": "Qualität vor Download abfragen",
"@downloadAskQuality": {
"description": "Toggle to show quality picker"
},
"downloadAskQualitySubtitle": "Show quality picker for each download",
"downloadAskQualitySubtitle": "Qualitätsauswahl für jeden Download anzeigen",
"@downloadAskQualitySubtitle": {
"description": "Subtitle for ask quality toggle"
},
"downloadFilenameFormat": "Filename Format",
"downloadFilenameFormat": "Dateinamenformat",
"@downloadFilenameFormat": {
"description": "Setting for output filename pattern"
},
"downloadFolderOrganization": "Folder Organization",
"downloadFolderOrganization": "Ordnerstruktur",
"@downloadFolderOrganization": {
"description": "Setting for folder structure"
},
"downloadSeparateSingles": "Separate Singles",
"downloadSeparateSingles": "Singles trennen",
"@downloadSeparateSingles": {
"description": "Toggle to separate single tracks"
},
"downloadSeparateSinglesSubtitle": "Put single tracks in a separate folder",
"downloadSeparateSinglesSubtitle": "Einzelne Titel in separatem Ordner speichern",
"@downloadSeparateSinglesSubtitle": {
"description": "Subtitle for separate singles toggle"
},
"qualityBest": "Best Available",
"qualityBest": "Beste Qualität",
"@qualityBest": {
"description": "Audio quality option - highest available"
},
@@ -219,11 +219,11 @@
"@quality128": {
"description": "Audio quality option - 128kbps MP3"
},
"appearanceTitle": "Appearance",
"appearanceTitle": "Erscheinungsbild",
"@appearanceTitle": {
"description": "Appearance settings page title"
},
"appearanceTheme": "Theme",
"appearanceTheme": "Design",
"@appearanceTheme": {
"description": "Theme mode setting"
},
@@ -231,55 +231,55 @@
"@appearanceThemeSystem": {
"description": "Follow system theme"
},
"appearanceThemeLight": "Light",
"appearanceThemeLight": "Hell",
"@appearanceThemeLight": {
"description": "Light theme"
},
"appearanceThemeDark": "Dark",
"appearanceThemeDark": "Dunkel",
"@appearanceThemeDark": {
"description": "Dark theme"
},
"appearanceDynamicColor": "Dynamic Color",
"appearanceDynamicColor": "Dynamische Farben",
"@appearanceDynamicColor": {
"description": "Material You dynamic colors"
},
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
"appearanceDynamicColorSubtitle": "Farben von Ihrem Hintergrundbild verwenden",
"@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color"
},
"appearanceAccentColor": "Accent Color",
"appearanceAccentColor": "Akzentfarbe",
"@appearanceAccentColor": {
"description": "Custom accent color picker"
},
"appearanceHistoryView": "History View",
"appearanceHistoryView": "Verlaufsansicht",
"@appearanceHistoryView": {
"description": "Layout style for history"
},
"appearanceHistoryViewList": "List",
"appearanceHistoryViewList": "Liste",
"@appearanceHistoryViewList": {
"description": "List layout option"
},
"appearanceHistoryViewGrid": "Grid",
"appearanceHistoryViewGrid": "Raster",
"@appearanceHistoryViewGrid": {
"description": "Grid layout option"
},
"optionsTitle": "Options",
"optionsTitle": "Optionen",
"@optionsTitle": {
"description": "Options settings page title"
},
"optionsSearchSource": "Search Source",
"optionsSearchSource": "Suchquelle",
"@optionsSearchSource": {
"description": "Section for search provider settings"
},
"optionsPrimaryProvider": "Primary Provider",
"optionsPrimaryProvider": "Primärer Anbieter",
"@optionsPrimaryProvider": {
"description": "Main search provider setting"
},
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
"optionsPrimaryProviderSubtitle": "Dienst für die Suche nach Titelnamen.",
"@optionsPrimaryProviderSubtitle": {
"description": "Subtitle for primary provider"
},
"optionsUsingExtension": "Using extension: {extensionName}",
"optionsUsingExtension": "Erweiterung verwenden: {extensionName}",
"@optionsUsingExtension": {
"description": "Shows active extension name",
"placeholders": {
@@ -288,55 +288,55 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Tippen Sie auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
},
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallback": "Automatischer Fallback",
"@optionsAutoFallback": {
"description": "Auto-retry with other services"
},
"optionsAutoFallbackSubtitle": "Try other services if download fails",
"optionsAutoFallbackSubtitle": "Andere Dienste versuchen, wenn Download fehlschlägt",
"@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback"
},
"optionsUseExtensionProviders": "Use Extension Providers",
"optionsUseExtensionProviders": "Erweiterungs-Anbieter verwenden",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Erweiterungen werden zuerst versucht",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Nur integrierte Anbieter verwenden",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"optionsEmbedLyrics": "Liedtexte einbetten",
"@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files"
},
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
"optionsEmbedLyricsSubtitle": "Synchronisierte Liedtexte in FLAC-Dateien einbetten",
"@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics"
},
"optionsMaxQualityCover": "Max Quality Cover",
"optionsMaxQualityCover": "Maximale Cover-Qualität",
"@optionsMaxQualityCover": {
"description": "Download highest quality album art"
},
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
"optionsMaxQualityCoverSubtitle": "Cover in höchster Auflösung herunterladen",
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"optionsConcurrentDownloads": "Parallele Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"optionsConcurrentSequential": "Sequentiell (1 gleichzeitig)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"optionsConcurrentParallel": "{count} parallele Downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
@@ -345,67 +345,67 @@
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"optionsConcurrentWarning": "Parallele Downloads können Ratenlimitierung auslösen",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "Erweiterungs-Store",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
"optionsExtensionStoreSubtitle": "Store-Tab in Navigation anzeigen",
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
"optionsCheckUpdates": "Check for Updates",
"optionsCheckUpdates": "Nach Updates suchen",
"@optionsCheckUpdates": {
"description": "Auto update check toggle"
},
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
"optionsCheckUpdatesSubtitle": "Benachrichtigen, wenn neue Version verfügbar",
"@optionsCheckUpdatesSubtitle": {
"description": "Subtitle for update check"
},
"optionsUpdateChannel": "Update Channel",
"optionsUpdateChannel": "Update-Kanal",
"@optionsUpdateChannel": {
"description": "Stable vs preview releases"
},
"optionsUpdateChannelStable": "Stable releases only",
"optionsUpdateChannelStable": "Nur stabile Versionen",
"@optionsUpdateChannelStable": {
"description": "Only stable updates"
},
"optionsUpdateChannelPreview": "Get preview releases",
"optionsUpdateChannelPreview": "Vorschau-Versionen erhalten",
"@optionsUpdateChannelPreview": {
"description": "Include beta/preview updates"
},
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
"optionsUpdateChannelWarning": "Vorschau kann Fehler oder unvollständige Funktionen enthalten",
"@optionsUpdateChannelWarning": {
"description": "Warning about preview channel"
},
"optionsClearHistory": "Clear Download History",
"optionsClearHistory": "Download-Verlauf löschen",
"@optionsClearHistory": {
"description": "Delete all download history"
},
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
"optionsClearHistorySubtitle": "Alle heruntergeladenen Titel aus dem Verlauf entfernen",
"@optionsClearHistorySubtitle": {
"description": "Subtitle for clear history"
},
"optionsDetailedLogging": "Detailed Logging",
"optionsDetailedLogging": "Detaillierte Protokollierung",
"@optionsDetailedLogging": {
"description": "Enable verbose logs for debugging"
},
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
"optionsDetailedLoggingOn": "Detaillierte Protokolle werden aufgezeichnet",
"@optionsDetailedLoggingOn": {
"description": "Status when logging enabled"
},
"optionsDetailedLoggingOff": "Enable for bug reports",
"optionsDetailedLoggingOff": "Für Fehlerberichte aktivieren",
"@optionsDetailedLoggingOff": {
"description": "Status when logging disabled"
},
"optionsSpotifyCredentials": "Spotify Credentials",
"optionsSpotifyCredentials": "Spotify-Anmeldedaten",
"@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting"
},
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsConfigured": "Client-ID: {clientId}...",
"@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview",
"placeholders": {
@@ -414,35 +414,35 @@
}
}
},
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
"optionsSpotifyCredentialsRequired": "Erforderlich - zum Konfigurieren tippen",
"@optionsSpotifyCredentialsRequired": {
"description": "Prompt to set up credentials"
},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"optionsSpotifyWarning": "Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com",
"@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement"
},
"extensionsTitle": "Extensions",
"extensionsTitle": "Erweiterungen",
"@extensionsTitle": {
"description": "Extensions page title"
},
"extensionsInstalled": "Installed Extensions",
"extensionsInstalled": "Installierte Erweiterungen",
"@extensionsInstalled": {
"description": "Section header for installed extensions"
},
"extensionsNone": "No extensions installed",
"extensionsNone": "Keine Erweiterungen installiert",
"@extensionsNone": {
"description": "Empty state title"
},
"extensionsNoneSubtitle": "Install extensions from the Store tab",
"extensionsNoneSubtitle": "Erweiterungen aus dem Store-Tab installieren",
"@extensionsNoneSubtitle": {
"description": "Empty state subtitle"
},
"extensionsEnabled": "Enabled",
"extensionsEnabled": "Aktiviert",
"@extensionsEnabled": {
"description": "Extension status - active"
},
"extensionsDisabled": "Disabled",
"extensionsDisabled": "Deaktiviert",
"@extensionsDisabled": {
"description": "Extension status - inactive"
},
@@ -455,7 +455,7 @@
}
}
},
"extensionsAuthor": "by {author}",
"extensionsAuthor": "von {author}",
"@extensionsAuthor": {
"description": "Extension author credit",
"placeholders": {
@@ -464,55 +464,55 @@
}
}
},
"extensionsUninstall": "Uninstall",
"extensionsUninstall": "Deinstallieren",
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"extensionsSetAsSearch": "Set as Search Provider",
"extensionsSetAsSearch": "Als Suchanbieter festlegen",
"@extensionsSetAsSearch": {
"description": "Use extension for search"
},
"storeTitle": "Extension Store",
"storeTitle": "Erweiterungs-Store",
"@storeTitle": {
"description": "Store screen title"
},
"storeSearch": "Search extensions...",
"storeSearch": "Erweiterungen suchen...",
"@storeSearch": {
"description": "Store search placeholder"
},
"storeInstall": "Install",
"storeInstall": "Installieren",
"@storeInstall": {
"description": "Install extension button"
},
"storeInstalled": "Installed",
"storeInstalled": "Installiert",
"@storeInstalled": {
"description": "Already installed badge"
},
"storeUpdate": "Update",
"storeUpdate": "Aktualisieren",
"@storeUpdate": {
"description": "Update available button"
},
"aboutTitle": "About",
"aboutTitle": "Über",
"@aboutTitle": {
"description": "About page title"
},
"aboutContributors": "Contributors",
"aboutContributors": "Mitwirkende",
"@aboutContributors": {
"description": "Section for contributors"
},
"aboutMobileDeveloper": "Mobile version developer",
"aboutMobileDeveloper": "Mobile-Version Entwickler",
"@aboutMobileDeveloper": {
"description": "Role description for mobile dev"
},
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
"aboutOriginalCreator": "Schöpfer des ursprünglichen SpotiFLAC",
"@aboutOriginalCreator": {
"description": "Role description for original creator"
},
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"aboutLogoArtist": "Der talentierte Künstler, der unser wunderschönes App-Logo entworfen hat!",
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutSpecialThanks": "Special Thanks",
"aboutSpecialThanks": "Besonderer Dank",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
},
@@ -520,27 +520,27 @@
"@aboutLinks": {
"description": "Section for external links"
},
"aboutMobileSource": "Mobile source code",
"aboutMobileSource": "Mobiler Quellcode",
"@aboutMobileSource": {
"description": "Link to mobile GitHub repo"
},
"aboutPCSource": "PC source code",
"aboutPCSource": "PC Quellcode",
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutReportIssue": "Report an issue",
"aboutReportIssue": "Problem melden",
"@aboutReportIssue": {
"description": "Link to report bugs"
},
"aboutReportIssueSubtitle": "Report any problems you encounter",
"aboutReportIssueSubtitle": "Melde jedes Problem, die dir auftreten",
"@aboutReportIssueSubtitle": {
"description": "Subtitle for report issue"
},
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequest": "Feature vorschlagen",
"@aboutFeatureRequest": {
"description": "Link to suggest features"
},
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
"aboutFeatureRequestSubtitle": "Schlage neue Funktionen für die App vor",
"@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request"
},
@@ -548,11 +548,11 @@
"@aboutSupport": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
@@ -564,11 +564,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "Der Schöpfer der QQDL & HiFi API. Ohne diese API gäbe es keine Tidal-Downloads!",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "Der ursprüngliche Entwickler des HiFi-Projekts. Die Grundlage der Tidal-Integration!",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -576,7 +576,7 @@
"@aboutDoubleDouble": {
"description": "Name of Amazon API service - DO NOT TRANSLATE"
},
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
"aboutDoubleDoubleDesc": "Wundervolle API für Amazon Music Downloads.\nVielen Dank, dass Sie sie kostenlos zur Verfügung stellen!",
"@aboutDoubleDoubleDesc": {
"description": "Credit for DoubleDouble API"
},
@@ -584,7 +584,7 @@
"@aboutDabMusic": {
"description": "Name of Qobuz API service - DO NOT TRANSLATE"
},
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
"aboutDabMusicDesc": "Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!",
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+19
View File
@@ -290,6 +290,8 @@
"@aboutOriginalCreator": {"description": "Role description for original creator"},
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"@aboutLogoArtist": {"description": "Role description for logo artist"},
"aboutTranslators": "Translators",
"@aboutTranslators": {"description": "Section for translators"},
"aboutSpecialThanks": "Special Thanks",
"@aboutSpecialThanks": {"description": "Section for special thanks"},
"aboutLinks": "Links",
@@ -1320,6 +1322,16 @@
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
"qualityMp3": "MP3",
"@qualityMp3": {"description": "Quality option - MP3 lossy format"},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {"description": "Technical spec for MP3"},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {"description": "Setting - enable MP3 quality option"},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"},
"qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"},
@@ -1459,6 +1471,13 @@
},
"downloadedAlbumSelectToDelete": "Select tracks to delete",
"@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"},
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {"type": "int", "example": "1"}
}
},
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {"description": "Extension capability - utility functions"},
File diff suppressed because it is too large Load Diff
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+6
View File
@@ -440,6 +440,11 @@
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz",
"qualityMp3": "MP3",
"qualityMp3Subtitle": "320kbps (konversi dari FLAC)",
"enableMp3Option": "Aktifkan Opsi MP3",
"enableMp3OptionSubtitleOn": "Opsi kualitas MP3 tersedia",
"enableMp3OptionSubtitleOff": "Unduh FLAC lalu konversi ke MP3 320kbps",
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
@@ -660,6 +665,7 @@
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
"folderOrganizationNone": "Tidak ada",
+180 -142
View File
@@ -9,23 +9,23 @@
"@appDescription": {
"description": "App description shown in about page"
},
"navHome": "Home",
"navHome": "ホーム",
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navHistory": "History",
"navHistory": "履歴",
"@navHistory": {
"description": "Bottom navigation - History tab"
},
"navSettings": "Settings",
"navSettings": "設定",
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
"navStore": "Store",
"navStore": "ストア",
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
"homeTitle": "Home",
"homeTitle": "ホーム",
"@homeTitle": {
"description": "Home screen title"
},
@@ -59,7 +59,7 @@
"@historyTitle": {
"description": "History screen title"
},
"historyDownloading": "Downloading ({count})",
"historyDownloading": "ダウンロード中 ({count})",
"@historyDownloading": {
"description": "Tab showing active downloads count",
"placeholders": {
@@ -69,19 +69,19 @@
}
}
},
"historyDownloaded": "Downloaded",
"historyDownloaded": "ダウンロード済み",
"@historyDownloaded": {
"description": "Tab showing completed downloads"
},
"historyFilterAll": "All",
"historyFilterAll": "すべて",
"@historyFilterAll": {
"description": "Filter chip - show all items"
},
"historyFilterAlbums": "Albums",
"historyFilterAlbums": "アルバム",
"@historyFilterAlbums": {
"description": "Filter chip - show albums only"
},
"historyFilterSingles": "Singles",
"historyFilterSingles": "シングル",
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
@@ -127,31 +127,31 @@
"@historyNoSinglesSubtitle": {
"description": "Empty state subtitle for singles filter"
},
"settingsTitle": "Settings",
"settingsTitle": "設定",
"@settingsTitle": {
"description": "Settings screen title"
},
"settingsDownload": "Download",
"settingsDownload": "ダウンロード",
"@settingsDownload": {
"description": "Settings section - download options"
},
"settingsAppearance": "Appearance",
"settingsAppearance": "外観",
"@settingsAppearance": {
"description": "Settings section - visual customization"
},
"settingsOptions": "Options",
"settingsOptions": "オプション",
"@settingsOptions": {
"description": "Settings section - app options"
},
"settingsExtensions": "Extensions",
"settingsExtensions": "拡張",
"@settingsExtensions": {
"description": "Settings section - extension management"
},
"settingsAbout": "About",
"settingsAbout": "アプリについて",
"@settingsAbout": {
"description": "Settings section - app info"
},
"downloadTitle": "Download",
"downloadTitle": "ダウンロード",
"@downloadTitle": {
"description": "Download settings page title"
},
@@ -163,19 +163,19 @@
"@downloadLocationSubtitle": {
"description": "Subtitle for download location"
},
"downloadLocationDefault": "Default location",
"downloadLocationDefault": "デフォルトの場所",
"@downloadLocationDefault": {
"description": "Shown when using default folder"
},
"downloadDefaultService": "Default Service",
"downloadDefaultService": "デフォルトのサービス",
"@downloadDefaultService": {
"description": "Setting for preferred download service (Tidal/Qobuz/Amazon)"
},
"downloadDefaultServiceSubtitle": "Service used for downloads",
"downloadDefaultServiceSubtitle": "ダウンロードに使用したサービス",
"@downloadDefaultServiceSubtitle": {
"description": "Subtitle for default service"
},
"downloadDefaultQuality": "Default Quality",
"downloadDefaultQuality": "デフォルトの品質",
"@downloadDefaultQuality": {
"description": "Setting for audio quality"
},
@@ -187,7 +187,7 @@
"@downloadAskQualitySubtitle": {
"description": "Subtitle for ask quality toggle"
},
"downloadFilenameFormat": "Filename Format",
"downloadFilenameFormat": "ファイル名の形式",
"@downloadFilenameFormat": {
"description": "Setting for output filename pattern"
},
@@ -219,27 +219,27 @@
"@quality128": {
"description": "Audio quality option - 128kbps MP3"
},
"appearanceTitle": "Appearance",
"appearanceTitle": "外観",
"@appearanceTitle": {
"description": "Appearance settings page title"
},
"appearanceTheme": "Theme",
"appearanceTheme": "テーマ",
"@appearanceTheme": {
"description": "Theme mode setting"
},
"appearanceThemeSystem": "System",
"appearanceThemeSystem": "システム",
"@appearanceThemeSystem": {
"description": "Follow system theme"
},
"appearanceThemeLight": "Light",
"appearanceThemeLight": "ライト",
"@appearanceThemeLight": {
"description": "Light theme"
},
"appearanceThemeDark": "Dark",
"appearanceThemeDark": "ダーク",
"@appearanceThemeDark": {
"description": "Dark theme"
},
"appearanceDynamicColor": "Dynamic Color",
"appearanceDynamicColor": "ダイナミックカラー",
"@appearanceDynamicColor": {
"description": "Material You dynamic colors"
},
@@ -247,31 +247,31 @@
"@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color"
},
"appearanceAccentColor": "Accent Color",
"appearanceAccentColor": "アクセントカラー",
"@appearanceAccentColor": {
"description": "Custom accent color picker"
},
"appearanceHistoryView": "History View",
"appearanceHistoryView": "履歴の表示",
"@appearanceHistoryView": {
"description": "Layout style for history"
},
"appearanceHistoryViewList": "List",
"appearanceHistoryViewList": "リスト",
"@appearanceHistoryViewList": {
"description": "List layout option"
},
"appearanceHistoryViewGrid": "Grid",
"appearanceHistoryViewGrid": "グリッド",
"@appearanceHistoryViewGrid": {
"description": "Grid layout option"
},
"optionsTitle": "Options",
"optionsTitle": "オプション",
"@optionsTitle": {
"description": "Options settings page title"
},
"optionsSearchSource": "Search Source",
"optionsSearchSource": "検索ソース",
"@optionsSearchSource": {
"description": "Section for search provider settings"
},
"optionsPrimaryProvider": "Primary Provider",
"optionsPrimaryProvider": "プライマリーのプロバイダー",
"@optionsPrimaryProvider": {
"description": "Main search provider setting"
},
@@ -279,7 +279,7 @@
"@optionsPrimaryProviderSubtitle": {
"description": "Subtitle for primary provider"
},
"optionsUsingExtension": "Using extension: {extensionName}",
"optionsUsingExtension": "拡張の使用: {extensionName}",
"@optionsUsingExtension": {
"description": "Shows active extension name",
"placeholders": {
@@ -300,7 +300,7 @@
"@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback"
},
"optionsUseExtensionProviders": "Use Extension Providers",
"optionsUseExtensionProviders": "拡張のプロバイダーを使用する",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
},
@@ -308,11 +308,11 @@
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "内蔵のプロバイダーのみを使用する",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"optionsEmbedLyrics": "歌詞を埋め込む",
"@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files"
},
@@ -320,7 +320,7 @@
"@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics"
},
"optionsMaxQualityCover": "Max Quality Cover",
"optionsMaxQualityCover": "最大品質のカバー",
"@optionsMaxQualityCover": {
"description": "Download highest quality album art"
},
@@ -349,7 +349,7 @@
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "拡張ストア",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
@@ -357,7 +357,7 @@
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
"optionsCheckUpdates": "Check for Updates",
"optionsCheckUpdates": "更新を確認",
"@optionsCheckUpdates": {
"description": "Auto update check toggle"
},
@@ -365,15 +365,15 @@
"@optionsCheckUpdatesSubtitle": {
"description": "Subtitle for update check"
},
"optionsUpdateChannel": "Update Channel",
"optionsUpdateChannel": "更新チャンネル",
"@optionsUpdateChannel": {
"description": "Stable vs preview releases"
},
"optionsUpdateChannelStable": "Stable releases only",
"optionsUpdateChannelStable": "安定版リリースのみ",
"@optionsUpdateChannelStable": {
"description": "Only stable updates"
},
"optionsUpdateChannelPreview": "Get preview releases",
"optionsUpdateChannelPreview": "プレビューリリースを入手",
"@optionsUpdateChannelPreview": {
"description": "Include beta/preview updates"
},
@@ -401,11 +401,11 @@
"@optionsDetailedLoggingOff": {
"description": "Status when logging disabled"
},
"optionsSpotifyCredentials": "Spotify Credentials",
"optionsSpotifyCredentials": "Spotify の認証情報",
"@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting"
},
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsConfigured": "クライアント ID: {clientId}...",
"@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview",
"placeholders": {
@@ -422,23 +422,23 @@
"@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement"
},
"extensionsTitle": "Extensions",
"extensionsTitle": "拡張",
"@extensionsTitle": {
"description": "Extensions page title"
},
"extensionsInstalled": "Installed Extensions",
"extensionsInstalled": "インストール済みの拡張",
"@extensionsInstalled": {
"description": "Section header for installed extensions"
},
"extensionsNone": "No extensions installed",
"extensionsNone": "拡張はインストールされていません",
"@extensionsNone": {
"description": "Empty state title"
},
"extensionsNoneSubtitle": "Install extensions from the Store tab",
"extensionsNoneSubtitle": "ストアタブから拡張をインストール",
"@extensionsNoneSubtitle": {
"description": "Empty state subtitle"
},
"extensionsEnabled": "Enabled",
"extensionsEnabled": "有効",
"@extensionsEnabled": {
"description": "Extension status - active"
},
@@ -446,7 +446,7 @@
"@extensionsDisabled": {
"description": "Extension status - inactive"
},
"extensionsVersion": "Version {version}",
"extensionsVersion": "バージョン {version}",
"@extensionsVersion": {
"description": "Extension version display",
"placeholders": {
@@ -455,7 +455,7 @@
}
}
},
"extensionsAuthor": "by {author}",
"extensionsAuthor": "作者 {author}",
"@extensionsAuthor": {
"description": "Extension author credit",
"placeholders": {
@@ -464,43 +464,43 @@
}
}
},
"extensionsUninstall": "Uninstall",
"extensionsUninstall": "アンインストール",
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"extensionsSetAsSearch": "Set as Search Provider",
"extensionsSetAsSearch": "検索プロバイダーを設定",
"@extensionsSetAsSearch": {
"description": "Use extension for search"
},
"storeTitle": "Extension Store",
"storeTitle": "拡張ストア",
"@storeTitle": {
"description": "Store screen title"
},
"storeSearch": "Search extensions...",
"storeSearch": "拡張を検索...",
"@storeSearch": {
"description": "Store search placeholder"
},
"storeInstall": "Install",
"storeInstall": "インストール",
"@storeInstall": {
"description": "Install extension button"
},
"storeInstalled": "Installed",
"storeInstalled": "インストール済み",
"@storeInstalled": {
"description": "Already installed badge"
},
"storeUpdate": "Update",
"storeUpdate": "更新",
"@storeUpdate": {
"description": "Update available button"
},
"aboutTitle": "About",
"aboutTitle": "アプリについて",
"@aboutTitle": {
"description": "About page title"
},
"aboutContributors": "Contributors",
"aboutContributors": "貢献者",
"@aboutContributors": {
"description": "Section for contributors"
},
"aboutMobileDeveloper": "Mobile version developer",
"aboutMobileDeveloper": "モバイルバージョンの開発者",
"@aboutMobileDeveloper": {
"description": "Role description for mobile dev"
},
@@ -512,23 +512,23 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutSpecialThanks": "Special Thanks",
"aboutSpecialThanks": "スペシャルサンクス",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
},
"aboutLinks": "Links",
"aboutLinks": "リンク",
"@aboutLinks": {
"description": "Section for external links"
},
"aboutMobileSource": "Mobile source code",
"aboutMobileSource": "モバイル版のソースコード",
"@aboutMobileSource": {
"description": "Link to mobile GitHub repo"
},
"aboutPCSource": "PC source code",
"aboutPCSource": "PC 版のソースコード",
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutReportIssue": "Report an issue",
"aboutReportIssue": "Issue で報告する",
"@aboutReportIssue": {
"description": "Link to report bugs"
},
@@ -536,7 +536,7 @@
"@aboutReportIssueSubtitle": {
"description": "Subtitle for report issue"
},
"aboutFeatureRequest": "Feature request",
"aboutFeatureRequest": "機能の要望",
"@aboutFeatureRequest": {
"description": "Link to suggest features"
},
@@ -548,19 +548,19 @@
"@aboutSupport": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"aboutBuyMeCoffee": "コーヒーを買ってください",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"aboutBuyMeCoffeeSubtitle": "Ko-fi で開発をサポートします",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "App",
"aboutApp": "アプリ",
"@aboutApp": {
"description": "Section for app info"
},
"aboutVersion": "Version",
"aboutVersion": "バージョン",
"@aboutVersion": {
"description": "Version info label"
},
@@ -625,11 +625,11 @@
"@artistAlbums": {
"description": "Section header for artist albums"
},
"artistSingles": "Singles & EPs",
"artistSingles": "シングルと EP",
"@artistSingles": {
"description": "Section header for singles/EPs"
},
"artistCompilations": "Compilations",
"artistCompilations": "コンピレーション",
"@artistCompilations": {
"description": "Section header for compilations"
},
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -730,15 +744,15 @@
"@setupChooseFolder": {
"description": "Button to pick folder"
},
"setupContinue": "Continue",
"setupContinue": "続行",
"@setupContinue": {
"description": "Continue to next step button"
},
"setupSkip": "Skip for now",
"setupSkip": "今はスキップ",
"@setupSkip": {
"description": "Skip current step button"
},
"setupStorageAccessRequired": "Storage Access Required",
"setupStorageAccessRequired": "ストレージアクセスが必要です",
"@setupStorageAccessRequired": {
"description": "Title when storage access needed"
},
@@ -841,7 +855,7 @@
"@setupStepSpotify": {
"description": "Setup step indicator - Spotify API"
},
"setupStepPermission": "Permission",
"setupStepPermission": "権限",
"@setupStepPermission": {
"description": "Setup step indicator - permission"
},
@@ -861,7 +875,7 @@
"@setupNotificationGranted": {
"description": "Success message for notification permission"
},
"setupNotificationEnable": "Enable Notifications",
"setupNotificationEnable": "通知を有効化する",
"@setupNotificationEnable": {
"description": "Button to enable notifications"
},
@@ -869,7 +883,7 @@
"@setupNotificationDescription": {
"description": "Explanation for notifications"
},
"setupFolderSelected": "Download Folder Selected!",
"setupFolderSelected": "ダウンロードフォルダが選択済みです!",
"@setupFolderSelected": {
"description": "Success message for folder selection"
},
@@ -889,7 +903,7 @@
"@setupSelectFolder": {
"description": "Button to select folder"
},
"setupSpotifyApiOptional": "Spotify API (Optional)",
"setupSpotifyApiOptional": "Spotify API (任意)",
"@setupSpotifyApiOptional": {
"description": "Spotify API step title"
},
@@ -897,7 +911,7 @@
"@setupSpotifyApiDescription": {
"description": "Explanation for Spotify API"
},
"setupUseSpotifyApi": "Use Spotify API",
"setupUseSpotifyApi": "Spotify API を使用する",
"@setupUseSpotifyApi": {
"description": "Toggle to enable Spotify API"
},
@@ -905,15 +919,15 @@
"@setupEnterCredentialsBelow": {
"description": "Prompt to enter credentials"
},
"setupUsingDeezer": "Using Deezer (no account needed)",
"setupUsingDeezer": "Deezer を使用中 (アカウントは不要です)",
"@setupUsingDeezer": {
"description": "Status when using Deezer"
},
"setupEnterClientId": "Enter Spotify Client ID",
"setupEnterClientId": "Spotify クライアント ID を入力",
"@setupEnterClientId": {
"description": "Placeholder for client ID field"
},
"setupEnterClientSecret": "Enter Spotify Client Secret",
"setupEnterClientSecret": "Spotify クライアントシークレットを入力",
"@setupEnterClientSecret": {
"description": "Placeholder for client secret field"
},
@@ -937,15 +951,15 @@
"@setupNotificationBackgroundDescription": {
"description": "Detailed notification explanation"
},
"setupSkipForNow": "Skip for now",
"setupSkipForNow": "今はスキップ",
"@setupSkipForNow": {
"description": "Skip button text"
},
"setupBack": "Back",
"setupBack": "戻る",
"@setupBack": {
"description": "Back button text"
},
"setupNext": "Next",
"setupNext": "次へ",
"@setupNext": {
"description": "Next button text"
},
@@ -953,7 +967,7 @@
"@setupGetStarted": {
"description": "Final setup button"
},
"setupSkipAndStart": "Skip & Start",
"setupSkipAndStart": "スキップと開始",
"@setupSkipAndStart": {
"description": "Skip setup and start app"
},
@@ -1069,7 +1083,7 @@
"@dialogRemoveExtensionMessage": {
"description": "Dialog message - uninstall confirmation"
},
"dialogUninstallExtension": "Uninstall Extension?",
"dialogUninstallExtension": "拡張をアンインストールしますか?",
"@dialogUninstallExtension": {
"description": "Dialog title - uninstall extension"
},
@@ -1103,7 +1117,7 @@
}
}
},
"dialogImportPlaylistTitle": "Import Playlist",
"dialogImportPlaylistTitle": "プレイリストをインポート",
"@dialogImportPlaylistTitle": {
"description": "Dialog title - import CSV playlist"
},
@@ -1242,7 +1256,7 @@
"@snackbarFailedToUpdate": {
"description": "Snackbar - extension update error"
},
"errorRateLimited": "Rate Limited",
"errorRateLimited": "レート制限",
"@errorRateLimited": {
"description": "Error title - too many requests"
},
@@ -1509,7 +1523,7 @@
}
}
},
"updateDownload": "Download",
"updateDownload": "ダウンロード",
"@updateDownload": {
"description": "Update button - download update"
},
@@ -1537,7 +1551,7 @@
"@updateNewVersionReady": {
"description": "Update subtitle"
},
"updateCurrent": "Current",
"updateCurrent": "現在",
"@updateCurrent": {
"description": "Label for current version"
},
@@ -1669,15 +1683,15 @@
"@logClearLogsMessage": {
"description": "Clear logs confirmation message"
},
"logIspBlocking": "ISP BLOCKING DETECTED",
"logIspBlocking": "ISP のブロックを検出しました",
"@logIspBlocking": {
"description": "Error category - ISP blocking"
},
"logRateLimited": "RATE LIMITED",
"logRateLimited": "レート制限",
"@logRateLimited": {
"description": "Error category - rate limiting"
},
"logNetworkError": "NETWORK ERROR",
"logNetworkError": "ネットワークエラー",
"@logNetworkError": {
"description": "Error category - network issues"
},
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -1939,27 +1941,27 @@
"@trackMetadata": {
"description": "Tab title - track metadata"
},
"trackFileInfo": "File Info",
"trackFileInfo": "ファイル情報",
"@trackFileInfo": {
"description": "Tab title - file information"
},
"trackLyrics": "Lyrics",
"trackLyrics": "歌詞",
"@trackLyrics": {
"description": "Tab title - lyrics"
},
"trackFileNotFound": "File not found",
"trackFileNotFound": "ファイルがありません",
"@trackFileNotFound": {
"description": "Error - file doesn't exist"
},
"trackOpenInDeezer": "Open in Deezer",
"trackOpenInDeezer": "Deezer で開く",
"@trackOpenInDeezer": {
"description": "Action - open track in Deezer app"
},
"trackOpenInSpotify": "Open in Spotify",
"trackOpenInSpotify": "Spotify で開く",
"@trackOpenInSpotify": {
"description": "Action - open track in Spotify app"
},
"trackTrackName": "Track name",
"trackTrackName": "トラック名",
"@trackTrackName": {
"description": "Metadata label - track title"
},
@@ -2131,11 +2133,11 @@
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "内蔵の検索を使用する",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
"extensionAuthor": "Author",
"extensionAuthor": "作者",
"@extensionAuthor": {
"description": "Extension detail - author"
},
@@ -2143,7 +2145,7 @@
"@extensionId": {
"description": "Extension detail - unique ID"
},
"extensionError": "Error",
"extensionError": "エラー",
"@extensionError": {
"description": "Extension detail - error message"
},
@@ -2183,19 +2185,19 @@
"@extensionSettings": {
"description": "Section header - extension settings"
},
"extensionRemoveButton": "Remove Extension",
"extensionRemoveButton": "拡張を削除",
"@extensionRemoveButton": {
"description": "Button to uninstall extension"
},
"extensionUpdated": "Updated",
"extensionUpdated": "更新済み",
"@extensionUpdated": {
"description": "Extension detail - last update"
},
"extensionMinAppVersion": "Min App Version",
"extensionMinAppVersion": "最小のアプリバージョン",
"@extensionMinAppVersion": {
"description": "Extension detail - minimum app version"
},
"extensionCustomTrackMatching": "Custom Track Matching",
"extensionCustomTrackMatching": "カスタムトラックマッチング",
"@extensionCustomTrackMatching": {
"description": "Capability - custom track matching algorithm"
},
@@ -2234,11 +2236,11 @@
"@extensionsProviderPrioritySection": {
"description": "Section header - provider priority"
},
"extensionsInstalledSection": "Installed Extensions",
"extensionsInstalledSection": "インストール済みの拡張",
"@extensionsInstalledSection": {
"description": "Section header - installed extensions"
},
"extensionsNoExtensions": "No extensions installed",
"extensionsNoExtensions": "拡張はインストールされていません",
"@extensionsNoExtensions": {
"description": "Empty state - no extensions"
},
@@ -2246,7 +2248,7 @@
"@extensionsNoExtensionsSubtitle": {
"description": "Empty state subtitle"
},
"extensionsInstallButton": "Install Extension",
"extensionsInstallButton": "拡張をインストール",
"@extensionsInstallButton": {
"description": "Button to install extension from file"
},
@@ -2302,7 +2304,7 @@
"@extensionsErrorLoading": {
"description": "Error message when extension fails to load"
},
"qualityFlacLossless": "FLAC Lossless",
"qualityFlacLossless": "FLAC ロスレス",
"@qualityFlacLossless": {
"description": "Quality option - CD quality FLAC"
},
@@ -2310,19 +2312,19 @@
"@qualityFlacLosslessSubtitle": {
"description": "Technical spec for lossless"
},
"qualityHiResFlac": "Hi-Res FLAC",
"qualityHiResFlac": "ハイレゾ FLAC",
"@qualityHiResFlac": {
"description": "Quality option - high resolution FLAC"
},
"qualityHiResFlacSubtitle": "24-bit / up to 96kHz",
"qualityHiResFlacSubtitle": "24-bit / 最大 96kHz",
"@qualityHiResFlacSubtitle": {
"description": "Technical spec for hi-res"
},
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMax": "ハイレゾ FLAC 最大",
"@qualityHiResFlacMax": {
"description": "Quality option - maximum resolution FLAC"
},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"qualityHiResFlacMaxSubtitle": "24-bit / 最大 192kHz",
"@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max"
},
@@ -2334,11 +2336,11 @@
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
},
"downloadDirectory": "Download Directory",
"downloadDirectory": "ダウンロードディレクトリ",
"@downloadDirectory": {
"description": "Setting - download folder"
},
"downloadSeparateSinglesFolder": "Separate Singles Folder",
"downloadSeparateSinglesFolder": "シングルのフォルダを分割",
"@downloadSeparateSinglesFolder": {
"description": "Setting - separate folder for singles"
},
@@ -2422,11 +2424,11 @@
"@serviceSpotify": {
"description": "Service name - DO NOT TRANSLATE"
},
"appearanceAmoledDark": "AMOLED Dark",
"appearanceAmoledDark": "AMOLED ダーク",
"@appearanceAmoledDark": {
"description": "Theme option - pure black"
},
"appearanceAmoledDarkSubtitle": "Pure black background",
"appearanceAmoledDarkSubtitle": "ピュアブラックの背景",
"@appearanceAmoledDarkSubtitle": {
"description": "Subtitle for AMOLED dark"
},
@@ -2434,15 +2436,15 @@
"@appearanceChooseAccentColor": {
"description": "Color picker dialog title"
},
"appearanceChooseTheme": "Theme Mode",
"appearanceChooseTheme": "テーマモード",
"@appearanceChooseTheme": {
"description": "Theme picker dialog title"
},
"queueTitle": "Download Queue",
"queueTitle": "ダウンロードキュー",
"@queueTitle": {
"description": "Queue screen title"
},
"queueClearAll": "Clear All",
"queueClearAll": "すべて消去",
"@queueClearAll": {
"description": "Button - clear all queue items"
},
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
File diff suppressed because it is too large Load Diff
+613 -575
View File
File diff suppressed because it is too large Load Diff
+53 -15
View File
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1851,27 +1865,15 @@
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language selection"
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Setting title for language selection"
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Subtitle for language setting"
},
"languageSystem": "System Default",
"@languageSystem": {
"description": "Use device system language"
},
"languageEnglish": "English",
"@languageEnglish": {
"description": "English language option"
},
"languageIndonesian": "Bahasa Indonesia",
"@languageIndonesian": {
"description": "Indonesian language option"
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
@@ -2573,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+63 -1
View File
@@ -51,7 +51,7 @@
"@homeSupports": {
"description": "Info text about supported URL types"
},
"homeRecent": "Recent",
"homeRecent": "最新的",
"@homeRecent": {
"description": "Section header for recent searches"
},
@@ -642,6 +642,20 @@
}
}
},
"artistPopular": "Popular",
"@artistPopular": {
"description": "Section header for popular/top tracks"
},
"artistMonthlyListeners": "{count} monthly listeners",
"@artistMonthlyListeners": {
"description": "Monthly listener count display",
"placeholders": {
"count": {
"type": "String",
"description": "Formatted listener count"
}
}
},
"trackMetadataTitle": "Track Info",
"@trackMetadataTitle": {
"description": "Track metadata screen title"
@@ -1849,6 +1863,18 @@
"@sectionLayout": {
"description": "Settings section header"
},
"sectionLanguage": "Language",
"@sectionLanguage": {
"description": "Settings section header for language"
},
"appearanceLanguage": "App Language",
"@appearanceLanguage": {
"description": "Language setting title"
},
"appearanceLanguageSubtitle": "Choose your preferred language",
"@appearanceLanguageSubtitle": {
"description": "Language setting subtitle"
},
"settingsAppearanceSubtitle": "Theme, colors, display",
"@settingsAppearanceSubtitle": {
"description": "Appearance settings description"
@@ -2549,5 +2575,41 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
},
"recentTypeArtist": "Artist",
"@recentTypeArtist": {
"description": "Recent access item type - artist"
},
"recentTypeAlbum": "Album",
"@recentTypeAlbum": {
"description": "Recent access item type - album"
},
"recentTypeSong": "Song",
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
"name": {
"type": "String",
"description": "Playlist name"
}
}
},
"errorGeneric": "Error: {message}",
"@errorGeneric": {
"description": "Generic error message format",
"placeholders": {
"message": {
"type": "String",
"description": "Error message"
}
}
}
}
+6
View File
@@ -14,11 +14,17 @@ const int translationThreshold = 70;
/// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('ru'),
Locale('es', 'ES'),
Locale('id'),
Locale('pt', 'PT'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'ru',
'es_ES',
'id',
'pt_PT',
};
-5
View File
@@ -11,10 +11,8 @@ import 'package:spotiflac_android/services/share_intent_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize notification service
await NotificationService().initialize();
// Initialize share intent service
await ShareIntentService().initialize();
runApp(
@@ -48,11 +46,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
// Create directories if needed
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
// Initialize extension system
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
} catch (e) {
debugPrint('Failed to initialize extensions: $e');
@@ -61,7 +57,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override
Widget build(BuildContext context) {
// Eagerly initialize download history provider to load from storage
ref.watch(downloadHistoryProvider);
return widget.child;
}
+4
View File
@@ -31,6 +31,7 @@ class AppSettings {
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
final bool showExtensionStore; // Show Extension Store tab in navigation
final String locale; // App language: 'system', 'en', 'id', etc.
final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion)
const AppSettings({
this.defaultService = 'tidal',
@@ -60,6 +61,7 @@ class AppSettings {
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
this.showExtensionStore = true, // Default: show store
this.locale = 'system', // Default: follow system language
this.enableMp3Option = false, // Default: disabled
});
AppSettings copyWith({
@@ -91,6 +93,7 @@ class AppSettings {
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
bool? enableMp3Option,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -120,6 +123,7 @@ class AppSettings {
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
enableMp3Option: enableMp3Option ?? this.enableMp3Option,
);
}
+2
View File
@@ -36,6 +36,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
enableMp3Option: json['enableMp3Option'] as bool? ?? false,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -67,4 +68,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'enableMp3Option': instance.enableMp3Option,
};
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -175,12 +175,10 @@ class SearchBehavior {
/// Get thumbnail size based on configuration
/// Returns (width, height) tuple
(double, double) getThumbnailSize({double defaultSize = 56}) {
// If custom dimensions specified, use them
if (thumbnailWidth != null && thumbnailHeight != null) {
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
}
// Otherwise use ratio presets
switch (thumbnailRatio) {
case 'wide': // 16:9 - YouTube style
return (defaultSize * 16 / 9, defaultSize);
@@ -357,11 +355,12 @@ class QualitySpecificSetting {
class ExtensionSetting {
final String key;
final String label;
final String type; // 'string', 'number', 'boolean', 'select'
final String type; // 'string', 'number', 'boolean', 'select', 'button'
final dynamic defaultValue;
final String? description;
final List<String>? options; // For select type
final bool required;
final String? action; // For button type: JS function name to call
const ExtensionSetting({
required this.key,
@@ -371,6 +370,7 @@ class ExtensionSetting {
this.description,
this.options,
this.required = false,
this.action,
});
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
@@ -382,6 +382,7 @@ class ExtensionSetting {
description: json['description'] as String?,
options: (json['options'] as List<dynamic>?)?.cast<String>(),
required: json['required'] as bool? ?? false,
action: json['action'] as String?,
);
}
}
@@ -558,10 +559,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
_log.d('Set extension $extensionId enabled: $enabled');
// Get extension info before updating state
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
// Update local state
final extensions = state.extensions.map((e) {
if (e.id == extensionId) {
return e.copyWith(enabled: enabled);
@@ -571,18 +570,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(extensions: extensions);
// If disabling an extension, reset related settings
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
// If this extension was the search provider, clear it and reset to Deezer
if (settings.searchProvider == extensionId) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
}
// If this extension was the default download service, reset to Tidal
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
ref.read(settingsProvider.notifier).setDefaultService('tidal');
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
+41 -17
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _recentAccessKey = 'recent_access_history';
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
const _maxRecentItems = 20;
/// Types of items that can be accessed
@@ -75,19 +76,23 @@ class RecentAccessItem {
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
final bool isLoaded;
const RecentAccessState({
this.items = const [],
this.hiddenDownloadIds = const {},
this.isLoaded = false,
});
RecentAccessState copyWith({
List<RecentAccessItem>? items,
Set<String>? hiddenDownloadIds,
bool? isLoaded,
}) {
return RecentAccessState(
items: items ?? this.items,
hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds,
isLoaded: isLoaded ?? this.isLoaded,
);
}
@@ -104,20 +109,27 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
List<RecentAccessItem> items = [];
Set<String> hiddenIds = {};
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
final items = decoded
items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(items: items, isLoaded: true);
} catch (e) {
// Invalid JSON, start fresh
state = state.copyWith(isLoaded: true);
// Ignore parse errors
}
} else {
state = state.copyWith(isLoaded: true);
}
if (hiddenJson != null) {
hiddenIds = hiddenJson.toSet();
}
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
}
Future<void> _saveHistory() async {
@@ -126,6 +138,11 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
await prefs.setString(_recentAccessKey, json);
}
Future<void> _saveHiddenDownloads() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
@@ -201,29 +218,18 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
void _recordAccess(RecentAccessItem item) {
// Debug log
// ignore: avoid_print
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
// Remove any existing entry with same unique key
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
// Add new item at the beginning
updatedItems.insert(0, item);
// Limit to max items
if (updatedItems.length > _maxRecentItems) {
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
}
state = state.copyWith(items: updatedItems);
_saveHistory();
// Debug log
// ignore: avoid_print
print('[RecentAccess] Total items now: ${updatedItems.length}');
}
/// Remove a specific item from history
@@ -235,11 +241,29 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
_saveHistory();
}
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden);
_saveHiddenDownloads();
}
/// Check if a download is hidden from recents
bool isDownloadHidden(String downloadId) {
return state.hiddenDownloadIds.contains(downloadId);
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
_saveHistory();
}
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {});
_saveHiddenDownloads();
}
}
/// Provider instance
+9 -12
View File
@@ -22,13 +22,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
// Run migrations if needed
await _runMigrations(prefs);
// Apply Spotify credentials to Go backend on load
_applySpotifyCredentials();
// Sync logging state
LogBuffer.loggingEnabled = state.enableLogging;
}
}
@@ -38,16 +35,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) {
// Migration 1: Set metadataSource to 'deezer' for existing users
// Only apply if user hasn't enabled custom Spotify credentials
// (users with custom credentials likely prefer Spotify)
if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer');
await _saveSettings();
}
}
// Save current migration version
if (lastMigration < _currentMigrationVersion) {
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
}
@@ -60,7 +53,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
/// Apply current Spotify credentials to Go backend
Future<void> _applySpotifyCredentials() async {
// Only apply if both fields are set
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
@@ -68,8 +60,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
state.spotifyClientSecret,
);
}
// Note: If credentials are empty, Spotify API will return error
// User should use Deezer as metadata source instead
}
void setDefaultService(String service) {
@@ -113,7 +103,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setConcurrentDownloads(int count) {
// Clamp between 1 and 3
final clamped = count.clamp(1, 3);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
@@ -207,7 +196,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
// Sync logging state to LogBuffer
LogBuffer.loggingEnabled = enabled;
}
@@ -235,6 +223,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(locale: locale);
_saveSettings();
}
void setEnableMp3Option(bool enabled) {
state = state.copyWith(enableMp3Option: enabled);
// If MP3 is disabled and current quality is MP3, reset to LOSSLESS
if (!enabled && state.audioQuality == 'MP3') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+5 -36
View File
@@ -142,14 +142,11 @@ class TrackNotifier extends Notifier<TrackState> {
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during fetch
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// First, check if any extension can handle this URL
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -188,7 +185,6 @@ class TrackNotifier extends Notifier<TrackState> {
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Parse top tracks if available
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
@@ -209,13 +205,11 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// No extension handler found, try Spotify URL parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
final type = parsed['type'] as String;
// Use the new fallback-enabled method
Map<String, dynamic> metadata;
try {
@@ -225,7 +219,6 @@ class TrackNotifier extends Notifier<TrackState> {
// ignore: avoid_print
print('[FetchURL] Metadata fetch success');
} catch (e) {
// If fallback also fails, show error
// ignore: avoid_print
print('[FetchURL] Metadata fetch failed: $e');
rethrow;
@@ -252,7 +245,6 @@ class TrackNotifier extends Notifier<TrackState> {
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
// Pre-warm cache for album tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
@@ -265,7 +257,6 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
);
// Pre-warm cache for playlist tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
@@ -281,21 +272,17 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
// Preserve hasSearchText on error so user stays on search screen
if (!_isRequestValid(requestId)) return;
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
}
}
Future<void> search(String query, {String? metadataSource}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Check if extension providers should be used for search
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final hasActiveMetadataExtensions = extensionState.extensions.any(
@@ -308,7 +295,6 @@ class TrackNotifier extends Notifier<TrackState> {
searchProvider != null &&
searchProvider.isNotEmpty;
// Use Deezer or Spotify based on settings
final source = metadataSource ?? 'deezer';
_log.i(
@@ -318,14 +304,12 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> results;
List<Track> extensionTracks = [];
// Try extension providers first if enabled
if (useExtensions) {
try {
_log.d('Calling extension search API...');
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
_log.i('Extensions returned ${extResults.length} tracks');
// Parse extension results
for (final t in extResults) {
try {
extensionTracks.add(_parseSearchTrack(t));
@@ -338,7 +322,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Also search with built-in providers
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
@@ -359,13 +342,10 @@ class TrackNotifier extends Notifier<TrackState> {
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
// Parse tracks with error handling per item
final tracks = <Track>[];
// Add extension tracks first (they have priority)
tracks.addAll(extensionTracks);
// Add built-in provider tracks, avoiding duplicates by ISRC
final existingIsrcs = extensionTracks
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
.map((t) => t.isrc!)
@@ -376,7 +356,6 @@ class TrackNotifier extends Notifier<TrackState> {
try {
if (t is Map<String, dynamic>) {
final track = _parseSearchTrack(t);
// Skip if we already have this track from extensions
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
continue;
}
@@ -389,7 +368,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Parse artists with error handling per item
final artists = <SearchArtist>[];
for (int i = 0; i < artistList.length; i++) {
final a = artistList[i];
@@ -421,10 +399,8 @@ class TrackNotifier extends Notifier<TrackState> {
/// Perform custom search using a specific extension
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
@@ -439,7 +415,6 @@ class TrackNotifier extends Notifier<TrackState> {
_log.i('Custom search returned ${results.length} tracks');
// Parse tracks with error handling per item, setting source to extension ID
final tracks = <Track>[];
for (int i = 0; i < results.length; i++) {
final t = results[i];
@@ -502,7 +477,8 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (e) {
// Silently fail availability check
// Silently ignore availability check errors
// This is a background operation that shouldn't disrupt the user
}
}
@@ -554,7 +530,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
// Handle duration_ms which might be int or double
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
@@ -563,7 +538,6 @@ class TrackNotifier extends Notifier<TrackState> {
durationMs = durationValue.toInt();
}
// Get item_type - can be 'track', 'album', or 'playlist'
final itemType = data['item_type']?.toString();
return Track(
@@ -610,23 +584,18 @@ class TrackNotifier extends Notifier<TrackState> {
/// Pre-warm track ID cache for faster downloads
/// Runs in background, doesn't block UI
void _preWarmCacheForTracks(List<Track> tracks) {
// Only pre-warm if we have tracks with ISRC
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return;
// Build request list for Go backend
final cacheRequests = tracksWithIsrc.map((t) => {
'isrc': t.isrc!,
'track_name': t.name,
'artist_name': t.artistName,
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
'service': 'tidal', // Default to tidal for pre-warming
'service': 'tidal',
}).toList();
// Fire and forget - runs in background
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
// Silently ignore errors - this is just an optimization
});
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
}
}
+144 -69
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -60,12 +61,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// Record access for recent history
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
@@ -77,11 +82,46 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
});
// Priority: widget.tracks > cache > fetch
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
if (_tracks == null) {
_fetchTracks();
}
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
// Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top)
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
}
Future<void> _fetchTracks() async {
@@ -89,14 +129,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
try {
Map<String, dynamic> metadata;
// Check if this is a Deezer album ID (format: "deezer:123456")
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
} else {
// Spotify album - use fallback method
// ignore: avoid_print
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
final url = 'https://open.spotify.com/album/${widget.albumId}';
@@ -106,7 +144,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Store in cache
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
@@ -148,6 +185,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
@@ -172,74 +210,106 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
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,
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
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 - fade out when collapsing
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.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
)
: 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),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
@@ -249,6 +319,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -265,7 +337,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 8),
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
const SizedBox(height: 12),
if (tracks.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -413,7 +492,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -443,12 +521,10 @@ class _AlbumTrackItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
@@ -459,7 +535,6 @@ class _AlbumTrackItem extends ConsumerWidget {
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
+41 -34
View File
@@ -95,12 +95,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
String? _headerImageUrl;
int? _monthlyListeners;
String? _error;
// Sticky title state
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
@override
void initState() {
super.initState();
// Record access for recent history
// Setup scroll listener for sticky title
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.extensionId ??
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
@@ -112,18 +118,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
});
// If this is an extension artist, use provided data only - don't fetch from Spotify/Deezer
if (widget.extensionId != null) {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
// Extension artists don't need additional fetching
return;
}
// Priority: widget data > cache > fetch
// But always fetch if topTracks is missing (to get popular tracks)
final cached = _ArtistCache.get(widget.artistId);
if (widget.albums != null) {
@@ -132,7 +134,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
// If we have albums but no top tracks, fetch to get them
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
@@ -142,15 +143,29 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_headerImageUrl = cached.headerImageUrl;
_monthlyListeners = cached.monthlyListeners;
// If cache has no top tracks, fetch
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
} else {
_fetchDiscography();
}
}
void _onScroll() {
// Show title when scrolled past the header (280px trigger)
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
Future<void> _fetchDiscography() async {
setState(() => _isLoadingDiscography = true);
try {
@@ -159,14 +174,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
String? headerImage;
int? listeners;
// Check if this is a Deezer artist ID (format: "deezer:123456")
if (widget.artistId.startsWith('deezer:')) {
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
} else {
// Spotify artist - use extension handler via URL
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final result = await PlatformBridge.handleURLWithExtension(url);
@@ -175,7 +188,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
// Parse top tracks if available
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
@@ -184,14 +196,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
headerImage = artistData['header_image'] as String?;
listeners = artistData['listeners'] as int?;
} else {
// Fallback to Spotify API metadata
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
}
}
// Store in cache (preserve existing values if new ones are null)
final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners;
@@ -268,8 +278,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final singles = albums.where((a) => a.albumType == 'single').toList();
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold(
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
_buildHeader(context, colorScheme),
if (_isLoadingDiscography)
@@ -283,10 +294,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoadingDiscography && _error == null) ...[
// Popular tracks section
if (_topTracks != null && _topTracks!.isNotEmpty)
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
// Discography sections
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
if (singles.isNotEmpty)
@@ -302,8 +311,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
/// Build Spotify-style header with full-width image and artist name overlay
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
// Use header image if available, otherwise fall back to cover URL
// Prefer: fetched header > widget header > widget cover
String? imageUrl = _headerImageUrl;
if (imageUrl == null || imageUrl.isEmpty) {
imageUrl = widget.headerImageUrl;
@@ -316,7 +323,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
imageUrl.isNotEmpty &&
Uri.tryParse(imageUrl)?.hasAuthority == true;
// Format monthly listeners
String? listenersText;
final listeners = _monthlyListeners ?? widget.monthlyListeners;
if (listeners != null && listeners > 0) {
@@ -324,17 +330,31 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners));
}
return SliverAppBar(
return SliverAppBar(
expandedHeight: 380,
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.artistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background image - full width, no circular crop
if (hasValidImage)
CachedNetworkImage(
imageUrl: imageUrl,
@@ -354,7 +374,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
),
// Gradient overlay for text readability
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -370,7 +389,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
),
// Artist name and listeners at bottom
Positioned(
left: 16,
right: 16,
@@ -436,7 +454,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Widget _buildPopularSection(ColorScheme colorScheme) {
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
// Show max 5 tracks
final tracks = _topTracks!.take(5).toList();
return Column(
@@ -462,12 +479,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
/// Build a single popular track item with dynamic download status
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
// Watch download queue for this track's status
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
@@ -478,7 +493,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return InkWell(
@@ -487,7 +501,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Rank number
SizedBox(
width: 24,
child: Text(
@@ -499,7 +512,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
const SizedBox(width: 12),
// Album art
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: track.coverUrl != null
@@ -529,7 +541,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -554,7 +565,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
),
// Download button with status
_buildPopularDownloadButton(
track: track,
colorScheme: colorScheme,
@@ -738,7 +748,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album cover
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
@@ -768,7 +777,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
const SizedBox(height: 8),
// Album name
Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
@@ -778,7 +786,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// Year and track count
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
+253 -77
View File
@@ -3,6 +3,7 @@ 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:palette_generator/palette_generator.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
@@ -27,18 +28,74 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_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.coverUrl == null || widget.coverUrl!.isEmpty) return;
// Only use network images for palette extraction
final isNetworkUrl = widget.coverUrl!.startsWith('http://') ||
widget.coverUrl!.startsWith('https://');
if (!isNetworkUrl) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) {
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName;
final itemKey = '${item.albumName}|$itemArtist';
final albumKey = '${widget.albumName}|${widget.artistName}';
return itemKey == albumKey;
}).toList()
..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);
@@ -46,6 +103,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
});
}
/// Get unique disc numbers from tracks (sorted)
List<int> _getDiscNumbers(List<DownloadHistoryItem> tracks) {
final discNumbers = tracks
.map((t) => t.discNumber ?? 1)
.toSet()
.toList()
..sort();
return discNumbers;
}
/// Check if album has multiple discs
bool _hasMultipleDiscs(List<DownloadHistoryItem> tracks) {
return _getDiscNumbers(tracks).length > 1;
}
/// Get tracks for a specific disc
List<DownloadHistoryItem> _getTracksForDisc(List<DownloadHistoryItem> tracks, int discNumber) {
return tracks.where((t) => (t.discNumber ?? 1) == discNumber).toList();
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
@@ -159,19 +236,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
// Watch history and get tracks for this album (reactive!)
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final tracks = _getAlbumTracks(allHistoryItems);
// Auto-pop if album has less than 2 tracks (no longer an "album")
if (tracks.length < 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.pop(context);
});
return const SizedBox.shrink();
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: Center(
child: Text('No tracks found for this album'),
),
);
}
// Clean up selected IDs that no longer exist
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
@@ -191,6 +270,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
@@ -200,7 +280,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
],
),
// Bottom Selection Action Bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
@@ -216,69 +295,98 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
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,
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
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 - fade out when collapsing
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.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
),
leading: IconButton(
icon: Container(
@@ -393,16 +501,84 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
// Check if album has multiple discs
if (!_hasMultipleDiscs(tracks)) {
// Single disc - use simple list
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
// Multiple discs - build list with separators
final discNumbers = _getDiscNumbers(tracks);
final List<Widget> children = [];
for (final discNumber in discNumbers) {
final discTracks = _getTracksForDisc(tracks, discNumber);
if (discTracks.isEmpty) continue;
// Add disc separator
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
// Add tracks for this disc
for (final track in discTracks) {
children.add(
KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
}
return SliverList(
delegate: SliverChildListDelegate(children),
);
}
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
return 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),
),
),
],
),
);
}
-11
View File
@@ -75,7 +75,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
setState(() => _currentIndex = index);
switch (index) {
case 0:
// Already on home
break;
case 1:
context.push('/queue');
@@ -112,7 +111,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// URL Input
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
@@ -132,7 +130,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
),
// Error message
if (trackState.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
@@ -142,15 +139,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
),
// Loading indicator
if (trackState.isLoading)
LinearProgressIndicator(color: colorScheme.primary),
// Album/Playlist header
if (trackState.albumName != null || trackState.playlistName != null)
_buildHeader(trackState, colorScheme),
// Download All button
if (trackState.tracks.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
@@ -164,7 +158,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
),
// Track list
Expanded(
child: trackState.tracks.isEmpty
? _buildEmptyState(colorScheme)
@@ -252,7 +245,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
],
),
),
// Play all button
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
@@ -271,7 +263,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
final track = ref.watch(trackProvider).tracks[index];
final isCollection = track.isCollection;
// Determine subtitle text based on item type
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
@@ -329,11 +320,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
}
Future<void> _openCollection(Track track) async {
// Get the extension ID from the track source
final extensionId = track.source;
if (extensionId == null) return;
// Fetch album/playlist tracks using the extension
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
+406 -141
View File
File diff suppressed because it is too large Load Diff
+1 -22
View File
@@ -30,13 +30,12 @@ class _MainShellState extends ConsumerState<MainShell> {
late PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress; // For double-tap to exit
DateTime? _lastBackPress;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
// Check for updates after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
_setupShareListener();
@@ -44,14 +43,12 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _setupShareListener() {
// Check for pending URL that was received before listener was ready
final pendingUrl = ShareIntentService().consumePendingUrl();
if (pendingUrl != null) {
_log.d('Processing pending shared URL: $pendingUrl');
_handleSharedUrl(pendingUrl);
}
// Listen for future shared URLs with error handling
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
(url) {
_log.d('Received shared URL from stream: $url');
@@ -65,18 +62,13 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _handleSharedUrl(String url) {
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
Navigator.of(context).popUntil((route) => route.isFirst);
// Navigate to Home tab
if (_currentIndex != 0) {
_onNavTap(0);
}
// Fetch metadata for shared URL
ref.read(trackProvider.notifier).fetchFromUrl(url);
// Mark that user has searched (hide helper text)
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Show snackbar
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.loadingSharedLink)),
@@ -124,8 +116,6 @@ class _MainShellState extends ConsumerState<MainShell> {
void _onPageChanged(int index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
// Unfocus any text field when switching tabs to prevent keyboard from appearing
// Use primaryFocus for more aggressive unfocus that works with keep-alive widgets
FocusManager.instance.primaryFocus?.unfocus();
}
}
@@ -134,39 +124,32 @@ class _MainShellState extends ConsumerState<MainShell> {
void _handleBackPress() {
final trackState = ref.read(trackProvider);
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardVisible) {
FocusManager.instance.primaryFocus?.unfocus();
return;
}
// If on Home tab and showing recent access mode, exit it
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
// Also unfocus search bar when exiting recent access mode
FocusManager.instance.primaryFocus?.unfocus();
return;
}
// If on Home tab and has text in search bar or has content (but not loading), clear it
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
return;
}
// If not on Home tab, go to Home tab first
if (_currentIndex != 0) {
_onNavTap(0);
return;
}
// If loading, ignore back press
if (trackState.isLoading) {
return;
}
// Double-tap to exit
final now = DateTime.now();
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
SystemNavigator.pop();
@@ -189,7 +172,6 @@ class _MainShellState extends ConsumerState<MainShell> {
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Determine if we can pop (for predictive back animation)
@@ -202,7 +184,6 @@ class _MainShellState extends ConsumerState<MainShell> {
!trackState.isShowingRecentAccess &&
!isKeyboardVisible;
// Build tabs and destinations based on settings
final tabs = <Widget>[
const HomeTab(),
QueueTab(
@@ -255,7 +236,6 @@ class _MainShellState extends ConsumerState<MainShell> {
),
];
// Clamp current index if tabs changed
final maxIndex = tabs.length - 1;
if (_currentIndex > maxIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -275,7 +255,6 @@ class _MainShellState extends ConsumerState<MainShell> {
return;
}
// Handle back press manually when canPop is false
_handleBackPress();
},
child: Scaffold(
+168 -65
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -10,7 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Playlist detail screen with Material Expressive 3 design
class PlaylistScreen extends ConsumerWidget {
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
@@ -23,16 +24,66 @@ class PlaylistScreen extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
}
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_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.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, ref, colorScheme),
_buildInfoCard(context, colorScheme),
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, ref, colorScheme),
_buildTrackList(context, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
@@ -40,59 +91,114 @@ class PlaylistScreen extends ConsumerWidget {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (coverUrl != null)
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: coverUrl != null
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
),
),
],
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.playlistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
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 - fade out when collapsing
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.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.playlist_play, 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)),
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, WidgetRef ref, ColorScheme colorScheme) {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -105,7 +211,7 @@ class PlaylistScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -115,15 +221,15 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context, ref),
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
label: Text(context.l10n.downloadAllCount(tracks.length)),
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -149,25 +255,25 @@ class PlaylistScreen extends ConsumerWidget {
);
}
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
final track = widget.tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, ref, track),
onDownload: () => _downloadTrack(context, track),
),
);
},
childCount: tracks.length,
childCount: widget.tracks.length,
),
);
}
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
@@ -186,22 +292,22 @@ class PlaylistScreen extends ConsumerWidget {
}
}
void _downloadAll(BuildContext context, WidgetRef ref) {
if (tracks.isEmpty) return;
void _downloadAll(BuildContext context) {
if (widget.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
artistName: playlistName,
trackName: '${widget.tracks.length} tracks',
artistName: widget.playlistName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
}
}
}
@@ -217,12 +323,10 @@ class _PlaylistTrackItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
@@ -233,7 +337,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
+14 -64
View File
@@ -52,11 +52,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final Set<String> _pendingChecks = {};
static const int _maxCacheSize = 500;
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
// Filter page controller for swipe between All/Albums/Singles
PageController? _filterPageController;
final List<String> _filterModes = ['all', 'albums', 'singles'];
bool _isPageControllerInitialized = false;
@@ -66,7 +64,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
@override
void initState() {
super.initState();
// Will be initialized in build when we have access to ref
}
void _initializePageController() {
@@ -291,7 +288,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
) {
if (filterMode == 'all') return items;
// Count tracks per album
final albumCounts = <String, int>{};
for (final item in items) {
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
@@ -300,14 +296,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
switch (filterMode) {
case 'albums':
// Album = more than 1 track from same album in history
return items.where((item) {
final key =
'${item.albumName}|${item.albumArtist ?? item.artistName}';
return (albumCounts[key] ?? 0) > 1;
}).toList();
case 'singles':
// Single = only 1 track from that album in history
return items.where((item) {
final key =
'${item.albumName}|${item.albumArtist ?? item.artistName}';
@@ -320,7 +314,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
/// Count albums vs singles for filter chips
Map<String, int> _countAlbumsAndSingles(List<DownloadHistoryItem> items) {
// Count tracks per album
final albumCounts = <String, int>{};
for (final item in items) {
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
@@ -351,11 +344,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
albumMap.putIfAbsent(key, () => []).add(item);
}
// Only include albums with more than 1 track
final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map(
(e) {
final tracks = e.value;
// Sort tracks by track number
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
@@ -374,7 +365,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
},
).toList();
// Sort by latest download
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
return groupedAlbums;
@@ -388,7 +378,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
albumKeys.add(key);
}
// Count albums with more than 1 track
int count = 0;
for (final key in albumKeys) {
final trackCount = items
@@ -421,7 +410,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
@override
Widget build(BuildContext context) {
// Initialize page controller on first build
_initializePageController();
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
@@ -447,10 +435,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
// Group albums for Albums filter view
final groupedAlbums = _groupByAlbum(allHistoryItems);
// Count for filter chips
final counts = _countAlbumsAndSingles(allHistoryItems);
final albumCount = _countUniqueAlbums(allHistoryItems);
final singleCount = counts['singles'] ?? 0;
@@ -466,9 +452,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
},
child: Stack(
children: [
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
// App Bar - always normal style
// ScrollConfiguration disables stretch overscroll to fix _StretchController exception
// This is a known Flutter issue with NestedScrollView + Material 3 stretch indicator
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
overscroll: false,
),
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -502,7 +493,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Pause/Resume controls
if ((isProcessing || queuedCount > 0) &&
(queueItems.length > 1 || isPaused))
SliverToBoxAdapter(
@@ -548,10 +538,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
),
),
),
// Queue header
if (queueItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
@@ -562,10 +551,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
fontWeight: FontWeight.bold,
),
),
),
),
),
// Queue list
if (queueItems.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
@@ -577,7 +565,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}, childCount: queueItems.length),
),
// Filter chips (only show when history has items)
if (allHistoryItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
@@ -630,7 +617,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (notification is OverscrollNotification) {
final overscroll = notification.overscroll;
// At first page and overscrolling to the left -> push parent toward Home
if (page == 0 && overscroll < 0) {
final currentOffset = parentController.offset;
final targetOffset = (currentOffset + overscroll).clamp(
@@ -641,7 +627,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return true;
}
// At last page and overscrolling to the right -> push parent toward next tab
if (page == 2 && overscroll > 0) {
final currentOffset = parentController.offset;
final targetOffset = (currentOffset + overscroll).clamp(
@@ -653,32 +638,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
// Snap parent to nearest page when scroll ends
if (notification is ScrollEndNotification) {
if (page == 0 || page == 2) {
final currentPage = parentController.page ?? widget.parentPageIndex.toDouble();
final historyPage = widget.parentPageIndex.toDouble();
final offset = currentPage - historyPage;
// Only snap if we've moved the parent
if (offset.abs() > 0.01) {
// Use 0.3 threshold (30%)
if (offset < -0.3) {
// Swiped enough toward Home - animate to Home
parentController.animateToPage(
widget.parentPageIndex - 1,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
} else if (offset > 0.3) {
// Swiped enough toward next tab - animate to next
parentController.animateToPage(
widget.nextPageIndex ?? (widget.parentPageIndex + 1),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
} else {
// Not enough - instant jump back (no animation)
parentController.jumpToPage(widget.parentPageIndex);
}
}
@@ -692,7 +671,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
physics: const ClampingScrollPhysics(),
onPageChanged: _onFilterPageChanged,
children: [
// All tab
_buildFilterContent(
context: context,
colorScheme: colorScheme,
@@ -702,7 +680,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
queueItems: queueItems,
groupedAlbums: groupedAlbums,
),
// Albums tab
_buildFilterContent(
context: context,
colorScheme: colorScheme,
@@ -712,7 +689,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
queueItems: queueItems,
groupedAlbums: groupedAlbums,
),
// Singles tab
_buildFilterContent(
context: context,
colorScheme: colorScheme,
@@ -726,8 +702,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
), // ScrollConfiguration
// Bottom Selection Action Bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
@@ -760,7 +736,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return CustomScrollView(
slivers: [
// History section header
if (historyItems.isNotEmpty &&
queueItems.isEmpty &&
filterMode != 'albums')
@@ -791,7 +766,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Albums section header (when Albums filter is selected)
if (groupedAlbums.isNotEmpty &&
queueItems.isEmpty &&
filterMode == 'albums')
@@ -807,7 +781,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// History section header when queue has items
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
@@ -821,7 +794,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Albums Grid (when Albums filter is selected)
if (filterMode == 'albums' && groupedAlbums.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -843,7 +815,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// History - Grid or List (for All and Singles filter)
if (historyItems.isNotEmpty && filterMode != 'albums')
historyViewMode == 'grid'
? SliverPadding(
@@ -883,10 +854,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
colorScheme,
),
);
}, childCount: historyItems.length),
),
}, childCount: historyItems.length ),
),
// Empty state
if (queueItems.isEmpty &&
historyItems.isEmpty &&
(filterMode != 'albums' || groupedAlbums.isEmpty))
@@ -899,7 +869,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
)
else
// Add bottom padding when selection mode is active to avoid overlap with bottom bar
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 100 : 16),
),
@@ -968,7 +937,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album cover with track count badge
Expanded(
child: Stack(
children: [
@@ -994,7 +962,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
// Track count badge
Positioned(
right: 8,
bottom: 8,
@@ -1032,16 +999,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
const SizedBox(height: 8),
// Album name
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600 ),
),
// Artist name
Text(
album.artistName,
maxLines: 1,
@@ -1085,7 +1050,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle bar
Container(
width: 32,
height: 4,
@@ -1096,10 +1060,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Selection info row
Row(
children: [
// Close button
IconButton.filledTonal(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
@@ -1109,7 +1071,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 12),
// Selection count
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1130,7 +1091,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Select all toggle
TextButton.icon(
onPressed: () {
if (allSelected) {
@@ -1153,7 +1113,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
const SizedBox(height: 16),
// Delete button
SizedBox(
width: double.infinity,
child: FilledButton.icon(
@@ -1461,7 +1420,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
// Quality badge
if (item.quality != null && item.quality!.contains('bit'))
Positioned(
left: 4,
@@ -1490,7 +1448,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
// Play button
if (fileExists && !_isSelectionMode)
Positioned(
right: 4,
@@ -1511,7 +1468,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
// Error indicator
if (!fileExists && !_isSelectionMode)
Positioned(
right: 4,
@@ -1529,7 +1485,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
// Selection overlay
if (_isSelectionMode)
Positioned.fill(
child: Container(
@@ -1562,7 +1517,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
],
),
// Selection checkbox
if (_isSelectionMode)
Positioned(
right: 4,
@@ -1630,7 +1584,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Selection checkbox
if (_isSelectionMode) ...[
Container(
width: 24,
@@ -1657,7 +1610,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 12),
],
// Cover art
item.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -1684,7 +1636,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 12),
// Track info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1752,7 +1703,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(width: 8),
// Action buttons (hide in selection mode)
if (!_isSelectionMode)
Row(
mainAxisSize: MainAxisSize.min,
+142 -23
View File
@@ -18,7 +18,6 @@ class AboutPage extends StatelessWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -35,9 +34,7 @@ class AboutPage extends StatelessWidget {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
// When collapsed (expandRatio=0): left=56 to avoid back button
// When expanded (expandRatio=1): left=24 for normal padding
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
@@ -54,7 +51,6 @@ class AboutPage extends StatelessWidget {
),
),
// App header card with logo and description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
@@ -62,7 +58,6 @@ class AboutPage extends StatelessWidget {
),
),
// Contributors section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
),
@@ -91,7 +86,13 @@ class AboutPage extends StatelessWidget {
),
),
// Special Thanks section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutTranslators),
),
const SliverToBoxAdapter(
child: _TranslatorsSection(),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
),
@@ -128,7 +129,6 @@ class AboutPage extends StatelessWidget {
),
),
// Links section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
@@ -167,7 +167,6 @@ class AboutPage extends StatelessWidget {
),
),
// Support section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
),
@@ -185,7 +184,6 @@ class AboutPage extends StatelessWidget {
),
),
// App info section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
@@ -202,7 +200,6 @@ class AboutPage extends StatelessWidget {
),
),
// Copyright
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
@@ -217,7 +214,6 @@ class AboutPage extends StatelessWidget {
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
@@ -227,7 +223,6 @@ class AboutPage extends StatelessWidget {
static Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
// Use inAppBrowserView for reliable URL opening with app chooser
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
@@ -250,8 +245,6 @@ class _AppHeaderCard extends StatelessWidget {
padding: const EdgeInsets.all(24),
child: Column(
children: [
// App logo
// App logo
Container(
width: 88,
height: 88,
@@ -275,7 +268,6 @@ class _AppHeaderCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
// App name
Text(
AppInfo.appName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -283,7 +275,6 @@ class _AppHeaderCard extends StatelessWidget {
),
),
const SizedBox(height: 4),
// Version badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
@@ -299,7 +290,6 @@ class _AppHeaderCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
// Description
Text(
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
@@ -341,7 +331,6 @@ class _ContributorItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
// GitHub Avatar
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
@@ -372,7 +361,6 @@ class _ContributorItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Name and description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -391,7 +379,6 @@ class _ContributorItem extends StatelessWidget {
],
),
),
// GitHub icon
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
],
),
@@ -415,7 +402,140 @@ class _ContributorItem extends StatelessWidget {
}
}
/// Settings item with 40x40 icon area to align with contributor avatars
/// Translator data model
class _Translator {
final String name;
final String crowdinUsername;
final String language;
final String flag;
const _Translator({
required this.name,
required this.crowdinUsername,
required this.language,
required this.flag,
});
}
/// Translators section with compact chip-style layout
class _TranslatorsSection extends StatelessWidget {
const _TranslatorsSection();
static const List<_Translator> _translators = [
_Translator(
name: 'Pedro Marcondes',
crowdinUsername: 'justapedro',
language: 'Portuguese',
flag: '🇵🇹',
),
_Translator(
name: 'Credits 125',
crowdinUsername: 'credits125',
language: 'Spanish',
flag: '🇪🇸',
),
_Translator(
name: 'Владислав',
crowdinUsername: 'odinokiy_kot',
language: 'Russian',
flag: '🇷🇺',
),
_Translator(
name: 'Max',
crowdinUsername: 'amonoman',
language: 'German',
flag: '🇩🇪',
),
];
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: colorScheme.surfaceContainerHighest;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _translators.map((translator) => _TranslatorChip(
translator: translator,
)).toList(),
),
),
);
}
}
/// Individual translator chip
class _TranslatorChip extends StatelessWidget {
final _Translator translator;
const _TranslatorChip({required this.translator});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: () => _launchCrowdin(translator.crowdinUsername),
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text(
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
),
const SizedBox(width: 8),
Text(
translator.name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 6),
Text(
translator.flag,
style: const TextStyle(fontSize: 14),
),
],
),
),
),
);
}
Future<void> _launchCrowdin(String username) async {
final uri = Uri.parse('https://crowdin.com/profile/$username');
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
class _AboutSettingsItem extends StatelessWidget {
final IconData icon;
final String title;
@@ -446,7 +566,6 @@ class _AboutSettingsItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
// Icon with 40x40 size to match avatar
SizedBox(
width: 40,
height: 40,
@@ -21,7 +21,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -39,7 +38,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Preview Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -50,7 +48,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Color section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionColor),
),
@@ -80,10 +77,9 @@ class AppearanceSettingsPage extends ConsumerWidget {
onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color),
),
),
),
),
// Theme section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
),
@@ -109,7 +105,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Language section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLanguage),
),
@@ -126,7 +121,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Layout section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
),
@@ -143,7 +137,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Fill remaining for scroll
const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(height: 32),
@@ -174,7 +167,6 @@ class _ThemePreviewCard extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Decorative background blobs
Positioned(
top: -50,
right: -50,
@@ -200,7 +192,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
),
// Foreground "fake UI"
Center(
child: Container(
width: 260,
@@ -219,7 +210,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
child: Row(
children: [
// Fake Album Art
Container(
width: 108,
height: 108,
@@ -235,7 +225,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
const SizedBox(width: 16),
// Fake Text Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -288,7 +277,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
),
// Label badge
Positioned(
bottom: 12,
right: 12,
@@ -510,10 +498,7 @@ class _ThemeModeChip extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
@@ -640,7 +625,6 @@ class _ViewModeChip extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
@@ -710,21 +694,23 @@ class _LanguageSelector extends StatelessWidget {
required this.onChanged,
});
// All available languages (code, displayName, icon)
static const _allLanguages = [
static const _allLanguages = [
('system', 'System Default', Icons.phone_android),
('en', 'English', Icons.language),
('id', 'Bahasa Indonesia', Icons.language),
('de', 'Deutsch', Icons.language),
('es', 'Español', Icons.language),
('es_ES', 'Español (España)', Icons.language),
('fr', 'Français', Icons.language),
('hi', 'हिन्दी', Icons.language),
('ja', '日本語', Icons.language),
('ko', '한국어', Icons.language),
('nl', 'Nederlands', Icons.language),
('pt', 'Português', Icons.language),
('pt_PT', 'Português (Portugal)', Icons.language),
('ru', 'Русский', Icons.language),
('zh', '简体中文', Icons.language),
('zh_CN', '简体中文 (中国)', Icons.language),
('zh_TW', '繁體中文', Icons.language),
];
@@ -732,15 +718,12 @@ class _LanguageSelector extends StatelessWidget {
/// Uses filteredLocaleCodes from supported_locales.dart (generated file).
List<(String, String, IconData)> get _languages {
return _allLanguages.where((lang) {
// Always include 'system' option
if (lang.$1 == 'system') return true;
// Only include languages in the filtered set
return filteredLocaleCodes.contains(lang.$1);
}).toList();
}
String _getLanguageName(String code) {
// Search in all languages (not just filtered) for display name fallback
for (final lang in _allLanguages) {
if (lang.$1 == code) return lang.$2;
}
@@ -11,7 +11,6 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerWidget {
const DownloadSettingsPage({super.key});
// Built-in services that support quality options
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
@override
@@ -20,7 +19,6 @@ class DownloadSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
// Check if current service is built-in (supports quality options)
final isBuiltInService = _builtInServices.contains(settings.defaultService);
return PopScope(
@@ -28,7 +26,6 @@ class DownloadSettingsPage extends ConsumerWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -68,7 +65,6 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
// Service section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionService),
),
@@ -85,7 +81,6 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
// Quality section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
),
@@ -99,12 +94,22 @@ class DownloadSettingsPage extends ConsumerWidget {
? context.l10n.downloadAskQualitySubtitle
: 'Select a built-in service to enable',
value: settings.askQualityBeforeDownload,
// Not selected visually if extension is active
enabled: isBuiltInService,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
SettingsSwitchItem(
icon: Icons.audiotrack,
title: context.l10n.enableMp3Option,
subtitle: settings.enableMp3Option
? context.l10n.enableMp3OptionSubtitleOn
: context.l10n.enableMp3OptionSubtitleOff,
value: settings.enableMp3Option,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setEnableMp3Option(value),
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption(
title: context.l10n.qualityFlacLossless,
@@ -129,8 +134,18 @@ class DownloadSettingsPage extends ConsumerWidget {
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'),
showDivider: false,
showDivider: settings.enableMp3Option,
),
if (settings.enableMp3Option)
_QualityOption(
title: context.l10n.qualityMp3,
subtitle: context.l10n.qualityMp3Subtitle,
isSelected: settings.audioQuality == 'MP3',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('MP3'),
showDivider: false,
),
],
if (!isBuiltInService) ...[
Padding(
@@ -159,7 +174,6 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
// File settings section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
),
@@ -321,11 +335,9 @@ class DownloadSettingsPage extends ConsumerWidget {
String insertion = tag;
if (start > 0) {
final before = text.substring(0, start);
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
if (!before.trim().endsWith('-')) {
insertion = ' - $tag';
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
// If ends with '-' but no space, add space
insertion = ' $tag';
}
}
@@ -478,10 +490,8 @@ class DownloadSettingsPage extends ConsumerWidget {
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
if (Platform.isIOS) {
// iOS: Show options dialog
_showIOSDirectoryOptions(context, ref);
} else {
// Android: Use file picker
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
@@ -697,18 +707,15 @@ class _ServiceSelector extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
// Get enabled extension download providers
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
.toList();
// Check if current service is an extension that's now disabled
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService)
: true;
// If current extension is disabled, show it as not selected
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
return Padding(
@@ -739,7 +746,6 @@ class _ServiceSelector extends ConsumerWidget {
),
],
),
// Show extension download providers if any
if (extensionProviders.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
@@ -755,7 +761,6 @@ class _ServiceSelector extends ConsumerWidget {
),
),
],
// Fill remaining space if less than 3 extensions
for (int i = extensionProviders.length; i < 3; i++) ...[
const SizedBox(width: 8),
const Expanded(child: SizedBox()),
+129 -33
View File
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget {
@@ -62,7 +63,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -98,7 +98,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
// Extension Info Card
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -202,7 +201,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
// Capabilities
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
),
@@ -254,9 +252,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
// URL Handler Section (if extension handles URLs)
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
@@ -272,7 +267,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Quality Options Section (for download providers)
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
@@ -291,7 +285,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Post-Processing Hooks (if available)
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
@@ -310,7 +303,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Permissions
if (extension.permissions.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
@@ -329,7 +321,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Settings
if (extension.settings.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
@@ -352,13 +343,13 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
value: _settings[setting.key] ?? setting.defaultValue,
showDivider: index < extension.settings.length - 1,
onChanged: (value) => _updateSetting(setting.key, value),
extensionId: widget.extensionId,
);
}).toList(),
),
),
],
// Remove button
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -424,7 +415,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
.read(extensionProvider.notifier)
.removeExtension(widget.extensionId);
if (success && mounted) {
// Refresh store to update isInstalled status
ref.read(storeProvider.notifier).refresh();
Navigator.pop(this.context);
}
@@ -557,7 +547,6 @@ class _PermissionItem extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Parse permission to get icon and description
IconData icon = Icons.security;
String description = permission;
@@ -600,41 +589,62 @@ class _PermissionItem extends StatelessWidget {
}
}
class _SettingItem extends StatelessWidget {
class _SettingItem extends StatefulWidget {
final ExtensionSetting setting;
final dynamic value;
final bool showDivider;
final ValueChanged<dynamic> onChanged;
final String extensionId;
const _SettingItem({
required this.setting,
required this.value,
required this.onChanged,
required this.extensionId,
this.showDivider = true,
});
@override
State<_SettingItem> createState() => _SettingItemState();
}
class _SettingItemState extends State<_SettingItem> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
Widget trailing;
switch (setting.type) {
switch (widget.setting.type) {
case 'boolean':
trailing = Switch(
value: value as bool? ?? false,
onChanged: onChanged,
value: widget.value as bool? ?? false,
onChanged: widget.onChanged,
);
break;
case 'select':
trailing = DropdownButton<String>(
value: value as String?,
items: setting.options?.map((opt) {
value: widget.value as String?,
items: widget.setting.options?.map((opt) {
return DropdownMenuItem(value: opt, child: Text(opt));
}).toList(),
onChanged: onChanged,
onChanged: widget.onChanged,
underline: const SizedBox(),
);
break;
case 'button':
trailing = _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: FilledButton.tonal(
onPressed: () => _invokeAction(context),
child: Text(widget.setting.label),
);
break;
default:
trailing = Icon(
Icons.chevron_right,
@@ -642,11 +652,52 @@ class _SettingItem extends StatelessWidget {
);
}
// For button type, show a different layout
if (widget.setting.type == 'button') {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.setting.description != null) ...[
Text(
widget.setting.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
],
],
),
),
trailing,
],
),
),
if (widget.showDivider)
Divider(
height: 1,
thickness: 1,
indent: 16,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: setting.type == 'string' || setting.type == 'number'
onTap: widget.setting.type == 'string' || widget.setting.type == 'number'
? () => _showEditDialog(context)
: null,
child: Padding(
@@ -658,22 +709,22 @@ class _SettingItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
setting.label,
widget.setting.label,
style: Theme.of(context).textTheme.bodyLarge,
),
if (setting.description != null) ...[
if (widget.setting.description != null) ...[
const SizedBox(height: 2),
Text(
setting.description!,
widget.setting.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
if (setting.type == 'string' || setting.type == 'number') ...[
if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[
const SizedBox(height: 4),
Text(
value?.toString() ?? 'Not set',
widget.value?.toString() ?? 'Not set',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
@@ -687,7 +738,7 @@ class _SettingItem extends StatelessWidget {
),
),
),
if (showDivider)
if (widget.showDivider)
Divider(
height: 1,
thickness: 1,
@@ -699,21 +750,66 @@ class _SettingItem extends StatelessWidget {
);
}
Future<void> _invokeAction(BuildContext context) async {
if (widget.setting.action == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No action defined for this button')),
);
return;
}
setState(() => _isLoading = true);
try {
final result = await PlatformBridge.invokeExtensionAction(
widget.extensionId,
widget.setting.action!,
);
if (context.mounted) {
final success = result['success'] as bool? ?? false;
if (!success) {
final error = result['error'] as String? ?? 'Action failed';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
} else {
final message = result['message'] as String?;
if (message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _showEditDialog(BuildContext context) {
final controller = TextEditingController(text: value?.toString() ?? '');
final controller = TextEditingController(text: widget.value?.toString() ?? '');
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(setting.label),
title: Text(widget.setting.label),
content: TextField(
controller: controller,
keyboardType: setting.type == 'number'
keyboardType: widget.setting.type == 'number'
? TextInputType.number
: TextInputType.text,
decoration: InputDecoration(
hintText: setting.description ?? 'Enter value',
hintText: widget.setting.description ?? 'Enter value',
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
border: OutlineInputBorder(
@@ -729,10 +825,10 @@ class _SettingItem extends StatelessWidget {
),
FilledButton(
onPressed: () {
final newValue = setting.type == 'number'
final newValue = widget.setting.type == 'number'
? num.tryParse(controller.text)
: controller.text;
onChanged(newValue);
widget.onChanged(newValue);
Navigator.pop(context);
},
child: Text(context.l10n.dialogSave),
-24
View File
@@ -32,7 +32,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
// Create directories if they don't exist
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
@@ -51,7 +50,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -87,7 +85,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Loading indicator
if (extState.isLoading)
const SliverToBoxAdapter(
child: Padding(
@@ -96,7 +93,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Error message
if (extState.error != null)
SliverToBoxAdapter(
child: Padding(
@@ -123,7 +119,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Provider Priority
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
),
@@ -137,7 +132,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Installed Extensions
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
),
@@ -203,7 +197,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Install button
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -221,7 +214,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Info section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
@@ -284,11 +276,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
if (success) {
message = context.l10n.extensionsInstalledSuccess;
} else {
// Parse friendly error message
message = _getFriendlyErrorMessage(extState.error);
}
// Clear the error from state to avoid showing it twice (in error container)
ref.read(extensionProvider.notifier).clearError();
ScaffoldMessenger.of(context).showSnackBar(
@@ -305,15 +295,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
String message = error;
// Remove PlatformException wrapper if present
// Format: PlatformException(ERROR, actual message, null, null)
if (message.contains('PlatformException')) {
// Try to extract the actual error message
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
if (match != null) {
message = match.group(1)?.trim() ?? message;
} else {
// Fallback: try simpler extraction
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
if (simpleMatch != null) {
message = simpleMatch.group(1)?.trim() ?? message;
@@ -321,7 +307,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
}
}
// Clean up any remaining artifacts
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
@@ -356,7 +341,6 @@ class _ExtensionItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Extension icon
Container(
width: 44,
height: 44,
@@ -390,7 +374,6 @@ class _ExtensionItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Extension info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -415,7 +398,6 @@ class _ExtensionItem extends StatelessWidget {
],
),
),
// Toggle switch
Switch(
value: extension.enabled,
onChanged: hasError ? null : onToggle,
@@ -445,7 +427,6 @@ class _DownloadPriorityItem extends ConsumerWidget {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Check if any extension has download provider
final hasDownloadExtensions = extState.extensions
.any((e) => e.enabled && e.hasDownloadProvider);
@@ -514,7 +495,6 @@ class _MetadataPriorityItem extends ConsumerWidget {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Check if any extension has metadata provider
final hasMetadataExtensions = extState.extensions
.any((e) => e.enabled && e.hasMetadataProvider);
@@ -584,12 +564,10 @@ class _SearchProviderSelector extends ConsumerWidget {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Get extensions with custom search
final searchProviders = extState.extensions
.where((e) => e.enabled && e.hasCustomSearch)
.toList();
// Get current provider name
String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
@@ -689,7 +667,6 @@ class _SearchProviderSelector extends ConsumerWidget {
),
),
),
// Default option
ListTile(
leading: Icon(Icons.music_note, color: colorScheme.primary),
title: Text(ctx.l10n.extensionDefaultProvider),
@@ -702,7 +679,6 @@ class _SearchProviderSelector extends ConsumerWidget {
Navigator.pop(ctx);
},
),
// Extension options
...searchProviders.map((ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
-26
View File
@@ -25,14 +25,12 @@ class _LogScreenState extends State<LogScreen> {
void initState() {
super.initState();
LogBuffer().addListener(_onLogUpdate);
// Start polling Go backend logs
LogBuffer().startGoLogPolling();
}
@override
void dispose() {
LogBuffer().removeListener(_onLogUpdate);
// Stop polling when leaving screen
LogBuffer().stopGoLogPolling();
_scrollController.dispose();
_searchController.dispose();
@@ -131,7 +129,6 @@ class _LogScreenState extends State<LogScreen> {
body: CustomScrollView(
controller: _scrollController,
slivers: [
// Collapsing App Bar with back button - same as other settings pages
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -208,14 +205,12 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Filter section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
// Level filter
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
@@ -269,7 +264,6 @@ class _LogScreenState extends State<LogScreen> {
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
// Search field
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
@@ -314,7 +308,6 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Log entries section
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
@@ -323,12 +316,10 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Error summary card - shows detected issues
SliverToBoxAdapter(
child: _LogSummaryCard(logs: LogBuffer().entries),
),
// Log list
logs.isEmpty
? SliverToBoxAdapter(
child: SettingsGroup(
@@ -379,7 +370,6 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
@@ -418,7 +408,6 @@ class _LogEntryTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: time, level, tag
Row(
children: [
Text(
@@ -478,7 +467,6 @@ class _LogEntryTile extends StatelessWidget {
],
),
const SizedBox(height: 6),
// Message
Text(
entry.message,
style: TextStyle(
@@ -488,7 +476,6 @@ class _LogEntryTile extends StatelessWidget {
height: 1.4,
),
),
// Error if present
if (entry.error != null) ...[
const SizedBox(height: 4),
Text(
@@ -526,10 +513,8 @@ class _LogSummaryCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Analyze logs for issues
final analysis = _analyzeLogs();
// Don't show if no issues detected
if (!analysis.hasIssues) {
return const SizedBox.shrink();
}
@@ -547,7 +532,6 @@ class _LogSummaryCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(
@@ -567,7 +551,6 @@ class _LogSummaryCard extends StatelessWidget {
),
const SizedBox(height: 12),
// ISP Blocking detected
if (analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.block,
@@ -580,7 +563,6 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
],
// Rate limiting
if (analysis.hasRateLimit) ...[
_IssueBadge(
icon: Icons.speed,
@@ -592,7 +574,6 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
],
// Network errors
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.wifi_off,
@@ -604,7 +585,6 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
],
// Track not found
if (analysis.hasNotFound) ...[
_IssueBadge(
icon: Icons.search_off,
@@ -615,7 +595,6 @@ class _LogSummaryCard extends StatelessWidget {
),
],
// Error count
const SizedBox(height: 12),
Text(
'Total errors: ${analysis.errorCount}',
@@ -647,7 +626,6 @@ class _LogSummaryCard extends StatelessWidget {
final errorLower = (log.error ?? '').toLowerCase();
final combined = '$msgLower $errorLower';
// Check for ISP blocking (detected by Go backend)
if (combined.contains('isp blocking') ||
combined.contains('isp may be') ||
combined.contains('blocked by isp') ||
@@ -655,21 +633,18 @@ class _LogSummaryCard extends StatelessWidget {
combined.contains('connection refused')) {
hasISPBlocking = true;
// Try to extract domain
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
if (domainMatch != null) {
blockedDomains.add(domainMatch.group(1)!);
}
}
// Check for rate limiting
if (combined.contains('rate limit') ||
combined.contains('429') ||
combined.contains('too many requests')) {
hasRateLimit = true;
}
// Check for network errors
if (combined.contains('connection') ||
combined.contains('timeout') ||
combined.contains('network') ||
@@ -677,7 +652,6 @@ class _LogSummaryCard extends StatelessWidget {
hasNetworkError = true;
}
// Check for not found
if (combined.contains('not found') ||
combined.contains('no results') ||
combined.contains('could not find')) {
@@ -24,16 +24,13 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
final extState = ref.read(extensionProvider);
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
// Use saved priority if available, otherwise use default order
if (extState.metadataProviderPriority.isNotEmpty) {
_providers = List.from(extState.metadataProviderPriority);
// Add any new providers not in saved priority
for (final provider in allProviders) {
if (!_providers.contains(provider)) {
_providers.add(provider);
}
}
// Remove providers that no longer exist
_providers.removeWhere((p) => !allProviders.contains(p));
} else {
_providers = allProviders;
@@ -57,7 +54,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -109,7 +105,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
),
),
// Description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -122,7 +117,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
),
),
// Provider list
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverReorderableList(
@@ -150,7 +144,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
),
),
// Info section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -258,7 +251,6 @@ class _MetadataProviderItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Priority number
Container(
width: 28,
height: 28,
@@ -281,7 +273,6 @@ class _MetadataProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Provider icon
Icon(
info.icon,
color: info.isBuiltIn
@@ -289,7 +280,6 @@ class _MetadataProviderItem extends StatelessWidget {
: colorScheme.secondary,
),
const SizedBox(width: 12),
// Provider name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -309,7 +299,6 @@ class _MetadataProviderItem extends StatelessWidget {
],
),
),
// Drag handle
Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
@@ -339,7 +328,6 @@ class _MetadataProviderItem extends StatelessWidget {
isBuiltIn: true,
);
default:
// Extension provider
return _MetadataProviderInfo(
name: provider,
icon: Icons.extension,
@@ -23,7 +23,6 @@ class OptionsSettingsPage extends ConsumerWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -63,7 +62,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Search Source section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
),
@@ -77,7 +75,6 @@ class OptionsSettingsPage extends ConsumerWidget {
.setMetadataSource(v),
),
if (settings.metadataSource == 'spotify') ...[
// Info card about Spotify credentials requirement
if (settings.spotifyClientId.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -130,7 +127,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Download options section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
),
@@ -179,7 +175,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Performance section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
),
@@ -196,7 +191,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// App section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionApp),
),
@@ -230,7 +224,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Data section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionData),
),
@@ -249,7 +242,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Debug section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
),
@@ -370,7 +362,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
const SizedBox(height: 32),
// Client ID
TextField(
controller: clientIdController,
decoration: InputDecoration(
@@ -408,7 +399,6 @@ class OptionsSettingsPage extends ConsumerWidget {
),
const SizedBox(height: 16),
// Client Secret
TextField(
controller: clientSecretController,
obscureText: true,
@@ -804,7 +794,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
// Check if extension search provider is active AND enabled
Extension? activeExtension;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
activeExtension = extState.extensions
@@ -846,10 +835,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip(
icon: Icons.graphic_eq,
label: 'Deezer',
// Not selected if extension is active
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
onTap: () {
// If extension was active, reset it to default
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
@@ -860,10 +847,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip(
icon: Icons.music_note,
label: 'Spotify',
// Not selected if extension is active
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
onTap: () {
// If extension was active, reset it to default
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
@@ -24,17 +24,13 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
final extState = ref.read(extensionProvider);
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
// Use saved priority if available, otherwise use default order
if (extState.providerPriority.isNotEmpty) {
// Start with saved priority
_providers = List.from(extState.providerPriority);
// Add any new providers not in saved priority
for (final provider in allProviders) {
if (!_providers.contains(provider)) {
_providers.add(provider);
}
}
// Remove providers that no longer exist
_providers.removeWhere((p) => !allProviders.contains(p));
} else {
_providers = allProviders;
@@ -58,7 +54,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -110,7 +105,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
),
// Description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -123,7 +117,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
),
// Provider list
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverReorderableList(
@@ -151,7 +144,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
),
// Info section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -246,7 +238,6 @@ class _ProviderItem extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
// Get provider info
final info = _getProviderInfo(provider);
return Padding(
@@ -260,7 +251,6 @@ class _ProviderItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Priority number
Container(
width: 28,
height: 28,
@@ -283,7 +273,6 @@ class _ProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Provider icon
Icon(
info.icon,
color: info.isBuiltIn
@@ -291,7 +280,6 @@ class _ProviderItem extends StatelessWidget {
: colorScheme.secondary,
),
const SizedBox(width: 12),
// Provider name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -311,7 +299,6 @@ class _ProviderItem extends StatelessWidget {
],
),
),
// Drag handle
Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
@@ -345,7 +332,6 @@ class _ProviderItem extends StatelessWidget {
isBuiltIn: true,
);
default:
// Extension provider
return _ProviderInfo(
name: provider,
icon: Icons.extension,
-8
View File
@@ -20,7 +20,6 @@ class SettingsTab extends ConsumerWidget {
return CustomScrollView(
slivers: [
// Collapsing App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -54,7 +53,6 @@ class SettingsTab extends ConsumerWidget {
),
),
// First group: Appearance & Download
SliverToBoxAdapter(
child: Builder(
builder: (context) {
@@ -94,7 +92,6 @@ class SettingsTab extends ConsumerWidget {
),
),
// Second group: Logs & About
SliverToBoxAdapter(
child: Builder(
builder: (context) {
@@ -120,23 +117,18 @@ class SettingsTab extends ConsumerWidget {
),
),
// Fill remaining space
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
);
}
void _navigateTo(BuildContext context, Widget page) {
// Unfocus any focused widget before navigating to prevent keyboard from appearing on return
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(
// Use PageRouteBuilder for better predictive back gesture support
// MaterialPageRoute can cause freeze on some devices with gesture navigation
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Use slide transition similar to MaterialPageRoute
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
const curve = Curves.easeInOut;
+1 -40
View File
@@ -25,13 +25,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool _isLoading = false;
int _androidSdkVersion = 0;
// Spotify API credentials
final _clientIdController = TextEditingController();
final _clientSecretController = TextEditingController();
bool _useSpotifyApi = false;
bool _showClientSecret = false;
// Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
@override
@@ -66,22 +64,18 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
});
}
} else if (Platform.isAndroid) {
// Check storage permission
bool storageGranted = false;
if (_androidSdkVersion >= 33) {
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
final manageStatus = await Permission.manageExternalStorage.status;
final audioStatus = await Permission.audio.status;
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
} else if (_androidSdkVersion >= 30) {
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
final manageStatus = await Permission.manageExternalStorage.status;
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
storageGranted = manageStatus.isGranted;
} else {
// Android 10 and below: Use legacy storage permission
final storageStatus = await Permission.storage.status;
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
storageGranted = storageStatus.isGranted;
@@ -89,7 +83,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
debugPrint('[Permission] Final storageGranted=$storageGranted');
// Check notification permission (Android 13+)
PermissionStatus notificationStatus = PermissionStatus.granted;
if (_androidSdkVersion >= 33) {
notificationStatus = await Permission.notification.status;
@@ -115,9 +108,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool allGranted = false;
if (_androidSdkVersion >= 33) {
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
// First check/request MANAGE_EXTERNAL_STORAGE
var manageStatus = await Permission.manageExternalStorage.status;
if (!manageStatus.isGranted) {
if (mounted) {
@@ -144,14 +134,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
// Re-check after returning from settings
await Future.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
}
}
// Then request READ_MEDIA_AUDIO (this shows a dialog)
var audioStatus = await Permission.audio.status;
if (!audioStatus.isGranted && manageStatus.isGranted) {
audioStatus = await Permission.audio.request();
@@ -160,7 +148,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
allGranted = manageStatus.isGranted && audioStatus.isGranted;
} else if (_androidSdkVersion >= 30) {
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
var manageStatus = await Permission.manageExternalStorage.status;
if (!manageStatus.isGranted) {
if (mounted) {
@@ -187,7 +174,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
// Re-check after returning from settings
await Future.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
@@ -196,7 +182,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
allGranted = manageStatus.isGranted;
} else {
// Android 10 and below: Use legacy storage permission
final status = await Permission.storage.request();
allGranted = status.isGranted;
@@ -239,7 +224,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_showPermissionDeniedDialog('Notification');
}
} else {
// Notification permission not needed for older Android
setState(() => _notificationPermissionGranted = true);
}
} catch (e) {
@@ -283,10 +267,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
try {
if (Platform.isIOS) {
// iOS: Show options dialog
await _showIOSDirectoryOptions();
} else {
// Android: Use file picker
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: context.l10n.setupSelectDownloadFolder,
);
@@ -359,7 +341,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
setState(() => _selectedDirectory = result);
@@ -436,7 +417,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
// Save Spotify credentials if provided
if (_useSpotifyApi &&
_clientIdController.text.trim().isNotEmpty &&
_clientSecretController.text.trim().isNotEmpty) {
@@ -444,10 +424,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_clientIdController.text.trim(),
_clientSecretController.text.trim(),
);
// Set search source to Spotify when credentials are provided
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
} else {
// Use Deezer as default search source (free, no credentials required)
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
}
@@ -482,7 +460,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Top section - Logo/Title
Column(
children: [
const SizedBox(height: 24),
@@ -501,7 +478,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
],
),
// Middle section - Steps and Content
Column(
children: [
const SizedBox(height: 24),
@@ -511,7 +487,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
],
),
// Bottom section - Navigation Buttons
Column(
children: [
const SizedBox(height: 24),
@@ -596,15 +571,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool _isStepCompleted(int step) {
if (_androidSdkVersion >= 33) {
// 4 steps: Storage, Notification, Folder, Spotify
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _notificationPermissionGranted;
case 2: return _selectedDirectory != null;
case 3: return false; // Spotify step never shows checkmark (optional)
case 3: return false;
}
} else {
// 3 steps: Permission, Folder, Spotify
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _selectedDirectory != null;
@@ -637,7 +610,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
@@ -691,7 +663,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
@@ -754,7 +725,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
@@ -829,7 +799,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon with container background (M3 style)
Container(
width: 80,
height: 80,
@@ -860,7 +829,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 24),
// Toggle card (M3 style)
Card(
elevation: 0,
color: colorScheme.surfaceContainerHigh,
@@ -891,7 +859,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
),
// Credentials form (animated)
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
@@ -906,7 +873,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Client ID
Text(context.l10n.credentialsClientId, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
@@ -925,7 +891,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 16),
// Client Secret
Text(context.l10n.credentialsClientSecret, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
@@ -949,7 +914,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
),
const SizedBox(height: 16),
// Info banner
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -983,14 +947,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final isLastStep = _currentStep == _totalSteps - 1;
final canProceed = _isStepCompleted(_currentStep);
// For Spotify step, check if credentials are valid when enabled
final isSpotifyStepValid = !_useSpotifyApi ||
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Back button
if (_currentStep > 0)
TextButton.icon(
onPressed: () => setState(() => _currentStep--),
@@ -1003,7 +965,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
else
const SizedBox(width: 100),
// Next/Finish button
if (!isLastStep)
FilledButton(
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
@@ -20,11 +20,8 @@ class _ExtensionDetailsScreenState
@override
Widget build(BuildContext context) {
// Watch store provider to get latest state of this extension (e.g. if updated/installed)
final storeState = ref.watch(storeProvider);
// Find our extension in the store state to get the latest status
// If not found in current store state (rare), fallback to widget.extension
final liveExtension =
storeState.extensions
.where((e) => e.id == widget.extension.id)
@@ -188,7 +185,6 @@ class _ExtensionDetailsScreenState
const SizedBox(height: 16),
// Badges row
Wrap(
spacing: 8,
runSpacing: 8,
@@ -215,7 +211,6 @@ class _ExtensionDetailsScreenState
const SizedBox(height: 24),
// Action Buttons
if (isDownloading)
Center(
child: CircularProgressIndicator(
@@ -410,7 +405,6 @@ class _ExtensionDetailsScreenState
StoreExtension ext,
ColorScheme colorScheme,
) {
// Determine capabilities based on category
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
final isDownloadProvider = ext.category == 'download';
final isLyricsProvider = ext.category == 'lyrics';
-13
View File
@@ -29,7 +29,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
final cacheDir = await getApplicationCacheDirectory();
// Check if widget is still mounted after async operation
if (!mounted) return;
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
@@ -53,7 +52,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
child: CustomScrollView(
slivers: [
// App Bar - consistent with other tabs
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -87,7 +85,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Search Bar
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -131,7 +128,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Category Chips
SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
@@ -203,7 +199,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Content
if (state.isLoading && state.extensions.isEmpty)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
@@ -215,7 +210,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
else if (state.filteredExtensions.isEmpty)
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
else ...[
// Extensions count
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -228,7 +222,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Extensions list in grouped card (like queue_tab)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -252,7 +245,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
],
@@ -457,7 +449,6 @@ class _ExtensionItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Extension icon - custom or category-based
Container(
width: 44,
height: 44,
@@ -507,7 +498,6 @@ class _ExtensionItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Extension info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -521,7 +511,6 @@ class _ExtensionItem extends StatelessWidget {
?.copyWith(fontWeight: FontWeight.w500),
),
),
// Version badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
@@ -548,7 +537,6 @@ class _ExtensionItem extends StatelessWidget {
color: colorScheme.onSurfaceVariant,
),
),
// Warning badge for incompatible extensions
if (extension.requiresNewerApp) ...[
const SizedBox(height: 4),
Container(
@@ -587,7 +575,6 @@ class _ExtensionItem extends StatelessWidget {
),
),
const SizedBox(width: 12),
// Action button
if (isDownloading)
const SizedBox(
width: 24,
+151 -91
View File
@@ -3,6 +3,7 @@ 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:palette_generator/palette_generator.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -28,6 +29,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? _lyrics;
bool _lyricsLoading = false;
String? _lyricsError;
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
String? _normalizeOptionalString(String? value) {
if (value == null) return null;
@@ -40,11 +44,45 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_checkFile();
_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.item.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.item.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
}
Future<void> _checkFile() async {
// Strip EXISTS: prefix from legacy history items
var filePath = widget.item.filePath;
if (filePath.startsWith('EXISTS:')) {
filePath = filePath.substring(7);
@@ -66,14 +104,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_fileSize = size;
});
// Auto-load lyrics if file exists (embedded lyrics are instant)
if (exists) {
_fetchLyrics();
}
}
}
// Use data directly from history item (cached from download)
DownloadHistoryItem get item => widget.item;
String get trackName => item.trackName;
String get artistName => item.artistName;
@@ -84,7 +120,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? get releaseDate => item.releaseDate;
String? get isrc => item.isrc;
// Clean filePath - strip EXISTS: prefix from legacy history items
String get cleanFilePath {
final path = item.filePath;
return path.startsWith('EXISTS:') ? path.substring(7) : path;
@@ -95,22 +130,48 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
// App Bar with cover art background
SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
flexibleSpace: FlexibleSpaceBar(
background: _buildHeaderBackground(context, colorScheme),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
trackName,
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: _buildHeaderBackground(context, colorScheme, coverSize, bgColor, showContent),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
),
leading: IconButton(
icon: Container(
@@ -138,34 +199,28 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
],
),
// Content
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track info card
_buildTrackInfoCard(context, colorScheme, _fileExists),
const SizedBox(height: 16),
// Metadata card
_buildMetadataCard(context, colorScheme, _fileSize),
const SizedBox(height: 16),
// File info card
_buildFileInfoCard(context, colorScheme, _fileExists, _fileSize),
const SizedBox(height: 16),
// Lyrics card
_buildLyricsCard(context, colorScheme),
const SizedBox(height: 24),
// Action buttons
_buildActionButtons(context, ref, colorScheme, _fileExists),
const SizedBox(height: 32),
@@ -178,77 +233,74 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme) {
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, Color bgColor, bool showContent) {
return Stack(
fit: StackFit.expand,
children: [
// Blurred background
if (item.coverUrl != null)
CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
),
// Gradient overlay
Container(
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover art centered
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Hero(
tag: 'cover_${item.id}',
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, _) => Container(
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Hero(
tag: 'cover_${item.id}',
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: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
placeholder: (_, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 48,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
@@ -268,7 +320,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track name (from file metadata)
Text(
trackName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -278,7 +329,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 4),
// Artist name (from file metadata)
Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
@@ -287,7 +337,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 8),
// Album name (from file metadata)
Row(
children: [
Icon(
@@ -307,7 +356,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
],
),
// File status
if (!fileExists) ...[
const SizedBox(height: 12),
Container(
@@ -372,10 +420,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 16),
// Metadata grid
_buildMetadataGrid(context, colorScheme),
// Streaming service link button
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
const SizedBox(height: 8),
Builder(
@@ -416,28 +462,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
: Uri.parse('spotify:track:$rawId');
try {
// Try to open in App first using URI scheme
final launched = await launchUrl(
appUri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
// Fallback to web URL which will redirect to app if installed
await launchUrl(
Uri.parse(webUrl),
mode: LaunchMode.externalApplication,
);
}
} catch (e) {
// If URI scheme fails, try web URL
try {
await launchUrl(
Uri.parse(webUrl),
mode: LaunchMode.externalApplication,
);
} catch (_) {
// Last resort: copy to clipboard
if (context.mounted) {
_copyToClipboard(context, webUrl);
ScaffoldMessenger.of(context).showSnackBar(
@@ -449,9 +491,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Build audio quality string from file metadata
// Determine audio quality string based on file type
String? audioQualityStr;
if (bitDepth != null && sampleRate != null) {
final fileName = item.filePath.split('/').last;
final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : '';
if (fileExt == 'MP3') {
audioQualityStr = '320kbps';
} else if (bitDepth != null && sampleRate != null) {
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
}
@@ -568,7 +615,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(height: 16),
// Format chip
Wrap(
spacing: 8,
runSpacing: 8,
@@ -604,7 +650,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
),
if (bitDepth != null && sampleRate != null)
// Show 320kbps for MP3, bit depth/sample rate for FLAC
if (fileExtension == 'MP3')
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'320kbps',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
)
else if (bitDepth != null && sampleRate != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
@@ -651,7 +714,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(height: 16),
// File path
InkWell(
onTap: () => _copyToClipboard(context, cleanFilePath),
borderRadius: BorderRadius.circular(12),
@@ -793,12 +855,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
});
try {
// Convert duration from seconds to milliseconds
final durationMs = (item.duration ?? 0) * 1000;
// Add timeout to prevent infinite loading
final result = await PlatformBridge.getLyricsLRC(
item.spotifyId ?? '',
item.trackName,
item.artistName,
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
durationMs: durationMs,
).timeout(
const Duration(seconds: 20),
onTimeout: () => '', // Return empty string on timeout
@@ -811,7 +877,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_lyricsLoading = false;
});
} else {
// Clean up LRC timestamps for display
final cleanLyrics = _cleanLrcForDisplay(result);
setState(() {
_lyrics = cleanLyrics;
@@ -833,7 +898,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
String _cleanLrcForDisplay(String lrc) {
// Remove LRC timestamps [mm:ss.xx] for cleaner display
final lines = lrc.split('\n');
final cleanLines = <String>[];
final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
@@ -851,7 +915,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) {
return Row(
children: [
// Play button
Expanded(
flex: 2,
child: FilledButton.icon(
@@ -868,7 +931,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
const SizedBox(width: 12),
// Delete button
Expanded(
child: OutlinedButton.icon(
onPressed: () => _confirmDelete(context, ref, colorScheme),
@@ -951,7 +1013,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
TextButton(
onPressed: () async {
// Delete the file first
try {
final file = File(cleanFilePath);
if (await file.exists()) {
@@ -961,7 +1022,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
debugPrint('Failed to delete file: $e');
}
// Remove from history
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
if (context.mounted) {
-3
View File
@@ -14,7 +14,6 @@ class ApkDownloader {
required String version,
ProgressCallback? onProgress,
}) async {
// Validate URL for security
final uri = Uri.tryParse(url);
if (uri == null || uri.scheme != 'https') {
_log.e('Refusing to download from invalid or non-HTTPS URL');
@@ -35,7 +34,6 @@ class ApkDownloader {
final contentLength = response.contentLength ?? 0;
// Get download directory
final dir = await getExternalStorageDirectory();
if (dir == null) {
_log.e('Could not get storage directory');
@@ -45,7 +43,6 @@ class ApkDownloader {
final filePath = '${dir.path}/SpotiFLAC-$version.apk';
final file = File(filePath);
// Delete if exists
if (await file.exists()) {
await file.delete();
}
+9 -32
View File
@@ -23,7 +23,6 @@ class CsvImportService {
final content = await file.readAsString();
final tracks = _parseCsv(content);
// Enrich tracks with metadata from Deezer (cover URL, duration, etc.)
if (tracks.isNotEmpty) {
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
}
@@ -48,11 +47,9 @@ class CsvImportService {
final track = tracks[i];
onProgress?.call(i + 1, tracks.length);
// Only enrich if missing cover/duration
if (track.coverUrl == null || track.duration == 0) {
Map<String, dynamic>? trackData;
// Try ISRC first if available
if (track.isrc != null && track.isrc!.isNotEmpty) {
try {
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
@@ -62,7 +59,6 @@ class CsvImportService {
}
}
// Fallback to text search if ISRC failed or not available
if (trackData == null) {
try {
final query = '${track.artistName} ${track.name}';
@@ -71,13 +67,11 @@ class CsvImportService {
if (searchResult.containsKey('tracks')) {
final tracksList = searchResult['tracks'] as List<dynamic>?;
if (tracksList != null && tracksList.isNotEmpty) {
// Find best match by comparing names
for (final result in tracksList) {
final resultMap = result as Map<String, dynamic>;
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? '';
final trackNameLower = track.name.toLowerCase();
// Check if track name matches (contains or equals)
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
trackData = resultMap;
_log.d('Text search match for ${track.name}: $resultName');
@@ -85,7 +79,6 @@ class CsvImportService {
}
}
// If no exact match, use first result
if (trackData == null && tracksList.isNotEmpty) {
trackData = tracksList.first as Map<String, dynamic>;
_log.d('Using first search result for ${track.name}');
@@ -97,7 +90,6 @@ class CsvImportService {
}
}
// Apply enriched data if found
if (trackData != null) {
final coverUrl = trackData['images'] as String?;
final durationMs = trackData['duration_ms'] as int? ?? 0;
@@ -119,7 +111,6 @@ class CsvImportService {
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
// Small delay to avoid rate limiting
if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 100));
}
@@ -127,7 +118,6 @@ class CsvImportService {
}
}
// Keep original track if enrichment failed or not needed
enrichedTracks.add(track);
}
@@ -137,10 +127,9 @@ class CsvImportService {
static List<Track> _parseCsv(String content) {
final List<Track> tracks = [];
final lines = content.split(RegExp(r'\r\n|\r|\n')); // Handle various newline formats
final lines = content.split(RegExp(r'\r\n|\r|\n'));
if (lines.isEmpty) return tracks;
// Detect headers line (assume first non-empty line)
int startIdx = 0;
while (startIdx < lines.length && lines[startIdx].trim().isEmpty) {
startIdx++;
@@ -150,37 +139,32 @@ class CsvImportService {
final headers = _parseLine(lines[startIdx]);
final colMap = <String, int>{};
for (int i = 0; i < headers.length; i++) {
// Normalize header: lowercase, trim, remove quotes
String h = _cleanValue(headers[i]).toLowerCase();
colMap[h] = i;
}
_log.d('CSV Headers: ${colMap.keys.toList()}');
// Parse rows
for (int i = startIdx + 1; i < lines.length; i++) {
final line = lines[i].trim();
if (line.isEmpty) continue;
final values = _parseLine(line);
// Helper to get value securely
String? getVal(List<String> keys) {
return _getValue(values, colMap, keys);
}
String? trackName = getVal(['track name', 'track', 'name', 'title']);
String? artistName = getVal(['artist name', 'artist']);
String? artistName = getVal(['artist name(s)', 'artist name', 'artist', 'artists']);
String? albumName = getVal(['album name', 'album']);
String? isrc = getVal(['isrc']); // Often formatted with leading/trailing quotes
String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']); // Uri might need parsing
String? isrc = getVal(['isrc']);
String? spotifyId = getVal(['track uri', 'spotify - id', 'spotify id', 'spotify_id', 'id', 'uri']);
// If 'spotify uri' contains the id: 'spotify:track:ID'
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.replaceAll('spotify:track:', '');
}
// Basic validation: Need at least name and artist, OR a spotify ID
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
tracks.add(Track(
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
@@ -215,28 +199,21 @@ class CsvImportService {
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
val = val.substring(1, val.length - 1);
}
// Handle double quotes escape in CSV ("" -> ")
val = val.replaceAll('""', '"');
return val;
}
// Robust CSV Line Parser
static List<String> _parseLine(String line) {
final List<String> result = [];
bool inQuote = false;
StringBuffer buffer = StringBuffer();
for (int i=0; i<line.length; i++) {
String char = line[i];
if (char == '"') {
// Look ahead to check for escaped quote
if (i + 1 < line.length && line[i+1] == '"') {
buffer.write('"'); // Keep format for now, _cleanValue handles unescaping logic differently...
// Wait, standard CSV: "Thumb ""Up""" -> Thumb "Up"
// My _cleanValue handles it, so I should just preserve raw content here mostly,
// BUT I need to know if " toggles inQuote.
// Escaped "" does NOT toggle inQuote mode effectively (it counts as literal char inside quote).
buffer.write('"'); // Write 1st quote
String char = line[i];
if (char == '"') {
if (i + 1 < line.length && line[i+1] == '"') {
buffer.write('"');
buffer.write('"');
i++; // Skip next quote char loop
buffer.write('"'); // Write 2nd quote
} else {
+147 -29
View File
@@ -31,14 +31,12 @@ class FFmpegService {
static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = inputPath.replaceAll('.m4a', '.flac');
// FFmpeg command to remux M4A to FLAC
final command =
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final result = await _execute(command);
if (result.success) {
// Delete original M4A file
try {
await File(inputPath).delete();
} catch (_) {}
@@ -50,19 +48,14 @@ class FFmpegService {
}
/// Convert FLAC to MP3
/// If deleteOriginal is true, deletes the FLAC file after conversion
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
bool deleteOriginal = true,
}) async {
final dir = File(inputPath).parent.path;
final baseName =
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}MP3';
// Create output directory
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
// Convert in same folder, just change extension
final outputPath = inputPath.replaceAll('.flac', '.mp3');
final command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
@@ -70,6 +63,12 @@ class FFmpegService {
final result = await _execute(command);
if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
@@ -88,18 +87,15 @@ class FFmpegService {
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}M4A';
// Create output directory
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
String command;
if (codec == 'alac') {
// ALAC - lossless
command =
'-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
} else {
// AAC - lossy
command =
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
}
@@ -141,25 +137,19 @@ class FFmpegService {
String? coverPath,
Map<String, String>? metadata,
}) async {
// Android Scoped Storage: Cannot write directly to Music folder with FFmpeg
// Use app-internal cache directory for temp output
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
// Construct command
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" ');
// Add cover input if available
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
// Map audio stream
cmdBuffer.write('-map 0:a ');
// Map cover stream if available
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v copy ');
@@ -168,13 +158,10 @@ class FFmpegService {
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
// Copy audio codec (don't re-encode)
cmdBuffer.write('-c:a copy ');
// Add text metadata
if (metadata != null) {
metadata.forEach((key, value) {
// Sanitize value: escape double quotes
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
@@ -189,18 +176,14 @@ class FFmpegService {
if (result.success) {
try {
// Copy temp output back to original location (replace)
final tempFile = File(tempOutput);
final originalFile = File(flacPath);
if (await tempFile.exists()) {
// Delete original file
if (await originalFile.exists()) {
await originalFile.delete();
}
// Copy temp file to original location
await tempFile.copy(flacPath);
// Delete temp file
await tempFile.delete();
return flacPath;
@@ -215,17 +198,152 @@ class FFmpegService {
}
}
// Clean up temp file if exists
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
} catch (e) {
_log.w('Failed to cleanup temp file: $e');
}
_log.e('Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({
required String mp3Path,
String? coverPath,
Map<String, String>? metadata,
}) async {
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 ');
cmdBuffer.write('-id3v2_version 3 ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command');
final result = await _execute(command);
if (result.success) {
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;
}
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup temp MP3 file: $e');
}
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
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;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
break;
case 'ARTIST':
id3Map['artist'] = value;
break;
case 'ALBUM':
id3Map['album'] = value;
break;
case 'ALBUMARTIST':
id3Map['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
id3Map['disc'] = value;
break;
case 'DATE':
case 'YEAR':
id3Map['date'] = value;
break;
case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
}
/// Result of FFmpeg command execution
-2
View File
@@ -32,7 +32,6 @@ class NotificationService {
await _notifications.initialize(initSettings);
// Create notification channel for Android
if (Platform.isAndroid) {
await _notifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
@@ -227,7 +226,6 @@ class NotificationService {
await _notifications.cancel(downloadProgressId);
}
// Update APK download notifications
Future<void> showUpdateDownloadProgress({
required String version,
required int received,
+53 -3
View File
@@ -129,6 +129,10 @@ class PlatformBridge {
String preferredService = 'tidal',
String? itemId,
int durationMs = 0,
// Extended metadata for FLAC tagging
String? genre,
String? label,
String? copyright,
}) async {
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
final request = jsonEncode({
@@ -151,6 +155,10 @@ class PlatformBridge {
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
'duration_ms': durationMs,
// Extended metadata
'genre': genre ?? '',
'label': label ?? '',
'copyright': copyright ?? '',
});
final result = await _channel.invokeMethod('downloadWithFallback', request);
@@ -236,32 +244,38 @@ class PlatformBridge {
}
/// Fetch lyrics for a track
/// [durationMs] is the track duration in milliseconds for better matching
static Future<Map<String, dynamic>> fetchLyrics(
String spotifyId,
String trackName,
String artistName,
) async {
String artistName, {
int durationMs = 0,
}) async {
final result = await _channel.invokeMethod('fetchLyrics', {
'spotify_id': spotifyId,
'track_name': trackName,
'artist_name': artistName,
'duration_ms': durationMs,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get lyrics in LRC format
/// First tries to extract from embedded file, then falls back to internet
/// [durationMs] is the track duration in milliseconds for better matching
static Future<String> getLyricsLRC(
String spotifyId,
String trackName,
String artistName, {
String? filePath,
int durationMs = 0,
}) async {
final result = await _channel.invokeMethod('getLyricsLRC', {
'spotify_id': spotifyId,
'track_name': trackName,
'artist_name': artistName,
'file_path': filePath ?? '',
'duration_ms': durationMs,
});
return result as String;
}
@@ -405,6 +419,25 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get extended metadata (genre, label) from Deezer using track ID
/// Returns {"genre": "...", "label": "..."} or null if not found
static Future<Map<String, String>?> getDeezerExtendedMetadata(String trackId) async {
try {
final result = await _channel.invokeMethod('getDeezerExtendedMetadata', {
'track_id': trackId,
});
if (result == null) return null;
final data = jsonDecode(result as String) as Map<String, dynamic>;
return {
'genre': data['genre'] as String? ?? '',
'label': data['label'] as String? ?? '',
};
} catch (e) {
_log.w('Failed to get Deezer extended metadata for $trackId: $e');
return null;
}
}
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
@@ -577,6 +610,20 @@ class PlatformBridge {
});
}
/// Invoke an action on an extension (e.g., button click handler like "startLogin")
/// Returns the result from the JS function
static Future<Map<String, dynamic>> invokeExtensionAction(String extensionId, String actionName) async {
_log.d('invokeExtensionAction: $extensionId.$actionName');
final result = await _channel.invokeMethod('invokeExtensionAction', {
'extension_id': extensionId,
'action': actionName,
});
if (result == null || (result as String).isEmpty) {
return {'success': true};
}
return jsonDecode(result) as Map<String, dynamic>;
}
/// Search tracks using extension providers
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
_log.d('searchTracksWithExtensions: "$query"');
@@ -609,6 +656,8 @@ class PlatformBridge {
String? itemId,
int durationMs = 0,
String? source, // Extension ID that provided this track (prioritize this extension)
String? genre,
String? label,
}) async {
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
final request = jsonEncode({
@@ -631,6 +680,8 @@ class PlatformBridge {
'item_id': itemId ?? '',
'duration_ms': durationMs,
'source': source ?? '', // Extension ID that provided this track
'genre': genre ?? '',
'label': label ?? '',
});
final result = await _channel.invokeMethod('downloadWithExtensions', request);
@@ -770,7 +821,6 @@ class PlatformBridge {
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
// No extension found or error handling URL
return null;
}
}
-8
View File
@@ -30,31 +30,26 @@ class ShareIntentService {
if (_initialized) return;
_initialized = true;
// Listen to media sharing coming from outside the app while the app is in memory
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
_handleSharedMedia,
onError: (err) => _log.e('Error: $err'),
);
// Get the media sharing coming from outside the app while the app is closed
final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia();
if (initialMedia.isNotEmpty) {
_handleSharedMedia(initialMedia, isInitial: true);
// Tell the library that we are done processing the intent
ReceiveSharingIntent.instance.reset();
}
}
void _handleSharedMedia(List<SharedMediaFile> files, {bool isInitial = false}) {
for (final file in files) {
// Check the path - for text shares, the path contains the shared text
final textToCheck = file.path;
final url = _extractSpotifyUrl(textToCheck);
if (url != null) {
_log.i('Received Spotify URL: $url (initial: $isInitial)');
if (isInitial) {
// Store for later - listener might not be ready yet
_pendingUrl = url;
}
_sharedUrlController.add(url);
@@ -71,18 +66,15 @@ class ShareIntentService {
String? _extractSpotifyUrl(String text) {
if (text.isEmpty) return null;
// Check for spotify: URI format
final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text);
if (uriMatch != null) {
return uriMatch.group(0);
}
// Check for open.spotify.com URL
final urlMatch = RegExp(
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
).firstMatch(text);
if (urlMatch != null) {
// Return URL without query params for cleaner handling
final fullUrl = urlMatch.group(0)!;
final queryIndex = fullUrl.indexOf('?');
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
-4
View File
@@ -65,7 +65,6 @@ class UpdateChecker {
Map<String, dynamic>? releaseData;
if (channel == 'preview') {
// For preview channel, get all releases and find the latest (including prereleases)
final response = await http.get(
Uri.parse('$_allReleasesApiUrl?per_page=10'),
headers: {'Accept': 'application/vnd.github.v3+json'},
@@ -82,10 +81,8 @@ class UpdateChecker {
return null;
}
// First release is the latest (including prereleases)
releaseData = releases.first as Map<String, dynamic>;
} else {
// For stable channel, use /latest endpoint (excludes prereleases)
final response = await http.get(
Uri.parse(_latestApiUrl),
headers: {'Accept': 'application/vnd.github.v3+json'},
@@ -124,7 +121,6 @@ class UpdateChecker {
final name = (asset['name'] as String? ?? '').toLowerCase();
if (name.endsWith('.apk')) {
final downloadUrl = asset['browser_download_url'] as String?;
// Only accept HTTPS URLs for security
final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null;
if (uri == null || uri.scheme != 'https') {
_log.w('Skipping non-HTTPS APK URL: $downloadUrl');
-3
View File
@@ -19,7 +19,6 @@ class DynamicColorWrapper extends ConsumerWidget {
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
// Determine which color scheme to use
ColorScheme lightScheme;
ColorScheme darkScheme;
@@ -28,7 +27,6 @@ class DynamicColorWrapper extends ConsumerWidget {
lightScheme = lightDynamic;
darkScheme = darkDynamic;
} else {
// Fallback to seed color
final seedColor = themeSettings.seedColor;
lightScheme = ColorScheme.fromSeed(
seedColor: seedColor,
@@ -45,7 +43,6 @@ class DynamicColorWrapper extends ConsumerWidget {
darkScheme = _applyAmoledColors(darkScheme);
}
// Build themes
final lightTheme = AppTheme.light(dynamicScheme: lightScheme);
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled);
-8
View File
@@ -55,7 +55,6 @@ class LogBuffer extends ChangeNotifier {
static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) {
_loggingEnabled = value;
// Also notify Go backend about logging state
if (value) {
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
} else {
@@ -121,7 +120,6 @@ class LogBuffer extends ChangeNotifier {
);
}
} catch (_) {
// Use current time if parsing fails
}
}
@@ -146,7 +144,6 @@ class LogBuffer extends ChangeNotifier {
void clear() {
_entries.clear();
_lastGoLogIndex = 0;
// Also clear Go backend logs
PlatformBridge.clearGoLogs().catchError((_) {});
notifyListeners();
}
@@ -191,14 +188,12 @@ class BufferedOutput extends LogOutput {
@override
void output(OutputEvent event) {
// Print to console in debug mode
if (kDebugMode) {
for (final line in event.lines) {
debugPrint(line);
}
}
// Add to buffer
final level = _levelToString(event.level);
final message = event.lines.join('\n');
@@ -249,8 +244,6 @@ class AppLogger {
late final Logger? _logger;
AppLogger(this._tag) {
// Only create Logger instance in debug mode
// In release mode, we write directly to LogBuffer
if (kDebugMode) {
_logger = Logger(
printer: SimplePrinter(printTime: false, colors: false),
@@ -276,7 +269,6 @@ class AppLogger {
if (kDebugMode) {
_logger?.d(message);
} else {
// In release mode, write directly to buffer
_addToBuffer('DEBUG', message);
}
}
-2
View File
@@ -64,7 +64,6 @@ class CollapsingHeader extends StatelessWidget {
),
),
// Info card if provided
if (infoCard != null)
SliverToBoxAdapter(
child: Padding(
@@ -73,7 +72,6 @@ class CollapsingHeader extends StatelessWidget {
),
),
// Content slivers
...slivers,
],
);
+23 -14
View File
@@ -49,6 +49,13 @@ const _builtInServices = [
),
];
/// MP3 quality option (shown when enabled in settings)
const _mp3QualityOption = QualityOption(
id: 'MP3',
label: 'MP3',
description: '320kbps (converted from FLAC)',
);
/// A reusable widget for selecting download service (built-in + extensions)
class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName;
@@ -105,23 +112,34 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
/// Get quality options for the selected service
List<QualityOption> _getQualityOptions() {
// Check if it's a built-in service
final settings = ref.read(settingsProvider);
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
if (builtIn != null) {
// Add MP3 option if enabled in settings
if (settings.enableMp3Option) {
return [...builtIn.qualityOptions, _mp3QualityOption];
}
return builtIn.qualityOptions;
}
// Check if it's an extension
final extensionState = ref.read(extensionProvider);
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
if (ext != null && ext.qualityOptions.isNotEmpty) {
// Add MP3 option for extensions too if enabled
if (settings.enableMp3Option) {
return [...ext.qualityOptions, _mp3QualityOption];
}
return ext.qualityOptions;
}
// Default quality options if extension doesn't specify any
return const [
QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
// Default fallback options
final defaultOptions = [
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
];
if (settings.enableMp3Option) {
return [...defaultOptions, _mp3QualityOption];
}
return defaultOptions;
}
@override
@@ -129,7 +147,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
final colorScheme = Theme.of(context).colorScheme;
final extensionState = ref.watch(extensionProvider);
// Get enabled download provider extensions
final downloadExtensions = extensionState.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.toList();
@@ -142,7 +159,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Track info header (if provided)
if (widget.trackName != null) ...[
_TrackInfoHeader(
trackName: widget.trackName!,
@@ -164,7 +180,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
],
// Service selector section
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
@@ -173,21 +188,18 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
),
// Built-in services
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
// Built-in services
for (final service in _builtInServices)
_ServiceChip(
label: service.label,
isSelected: _selectedService == service.id,
onTap: () => setState(() => _selectedService = service.id),
),
// Extension services
for (final ext in downloadExtensions)
_ServiceChip(
label: ext.displayName,
@@ -199,7 +211,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
),
// Quality selector section
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
@@ -208,7 +219,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
),
// Disclaimer for built-in services
if (_builtInServices.any((s) => s.id == _selectedService))
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
@@ -221,7 +231,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
),
),
// Quality options
for (final quality in qualityOptions)
_QualityOption(
title: quality.label,
-17
View File
@@ -30,7 +30,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
Future<void> _downloadAndInstall() async {
final apkUrl = widget.updateInfo.apkDownloadUrl;
// If no direct APK URL, open release page
if (apkUrl == null) {
final uri = Uri.parse(widget.updateInfo.downloadUrl);
if (await canLaunchUrl(uri)) {
@@ -60,7 +59,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
_statusText = '$receivedMB / $totalMB MB';
});
}
// Update notification
notificationService.showUpdateDownloadProgress(
version: widget.updateInfo.version,
received: received,
@@ -70,7 +68,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
);
if (filePath != null) {
// Cancel progress notification first
await notificationService.cancelUpdateNotification();
await notificationService.showUpdateDownloadComplete(
@@ -81,10 +78,8 @@ class _UpdateDialogState extends State<UpdateDialog> {
Navigator.pop(context);
}
// Open APK for installation
await ApkDownloader.installApk(filePath);
} else {
// Cancel progress notification first
await notificationService.cancelUpdateNotification();
await notificationService.showUpdateDownloadFailed();
@@ -116,7 +111,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with icon
Row(
children: [
Container(
@@ -142,7 +136,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
const SizedBox(height: 20),
// Version badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
@@ -165,7 +158,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
const SizedBox(height: 20),
// Download progress (when downloading)
if (_isDownloading) ...[
Container(
padding: const EdgeInsets.all(16),
@@ -209,7 +201,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
),
] else ...[
// Changelog section
Text(context.l10n.updateWhatsNew, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Container(
@@ -231,7 +222,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
],
const SizedBox(height: 24),
// Action buttons
if (_isDownloading)
SizedBox(
width: double.infinity,
@@ -303,19 +293,16 @@ class _UpdateDialogState extends State<UpdateDialog> {
String _formatChangelog(String changelog) {
var content = changelog;
// Find content after "What's New" header
final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content);
if (whatsNewMatch != null) {
content = content.substring(whatsNewMatch.end);
}
// Cut off at "Downloads" section or horizontal rule
final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content);
if (cutoffMatch != null) {
content = content.substring(0, cutoffMatch.start);
}
// Process line by line for better formatting
final lines = content.split('\n');
final formattedLines = <String>[];
@@ -323,7 +310,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
line = line.trim();
if (line.isEmpty) continue;
// Check if it's a section header
final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line);
if (sectionMatch != null) {
final section = sectionMatch.group(1)?.trim();
@@ -334,7 +320,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
continue;
}
// Check if it's a list item
final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line);
if (listMatch != null) {
var itemText = listMatch.group(1) ?? '';
@@ -344,7 +329,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
continue;
}
// Check if it's a sub-item
final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line);
if (subListMatch != null) {
var itemText = subListMatch.group(1) ?? '';
@@ -401,7 +385,6 @@ class _VersionChip extends StatelessWidget {
}
}
/// Show update dialog
Future<void> showUpdateDialog(
BuildContext context, {
required UpdateInfo updateInfo,

Some files were not shown because too many files have changed in this diff Show More