diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b8d64d08..301225e4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -441,20 +441,30 @@ jobs:
VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
- FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d')
+ # Use tr -d '\r' to handle CRLF line endings from Windows
+ FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
+
+ echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
+ echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details."
else
- # Convert GitHub Markdown to Telegram Markdown:
- # - **text** → *text* (GitHub bold to Telegram bold)
- # - ### Header → *Header* (headers to bold)
- # - Add extra line break before major list items for readability
+ # Convert GitHub Markdown to Telegram HTML:
+ # - **text** → text
+ # - `code` → code
+ # - ### Header → Header
+ # - Escape HTML special chars first
+ # - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
- sed 's/\*\*\([^*]*\)\*\*/*\1*/g' | \
- sed 's/^### \(.*\)$/*\1*/g' | \
- sed 's/^## \(.*\)$/*\1*/g' | \
- sed 's/^- \*\*\([^:]*\):\*\*/\n• *\1:*/g' | \
+ sed 's/^> //' | \
+ sed 's/&/\&/g' | \
+ sed 's/\</g' | \
+ sed 's/>/\>/g' | \
+ sed 's/`\([^`]*\)`/\1<\/code>/g' | \
+ sed 's/\*\*\([^*]*\)\*\*/\1<\/b>/g' | \
+ sed 's/^### \(.*\)$/\1<\/b>/g' | \
+ sed 's/^## \(.*\)$/\1<\/b>/g' | \
sed 's/^- /• /g' | \
sed 's/^ - / ◦ /g')
@@ -469,6 +479,8 @@ jobs:
fi
echo "$CHANGELOG" > /tmp/changelog.txt
+ echo "DEBUG: Final changelog:"
+ cat /tmp/changelog.txt
- name: Send to Telegram Channel
env:
@@ -482,23 +494,23 @@ jobs:
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
- # Prepare message with changelog (files uploaded separately)
+ # Prepare message with changelog (HTML format)
printf '%s\n' \
- "*SpotiFLAC Mobile ${VERSION} Released!*" \
+ "SpotiFLAC Mobile ${VERSION} Released!" \
"" \
- "*What's New:*" \
+ "What's New:" \
"${CHANGELOG}" \
"" \
- "[View Release Notes](https://github.com/${{ github.repository }}/releases/tag/${VERSION})" \
+ "View Release Notes" \
> /tmp/telegram_message.txt
MESSAGE=$(cat /tmp/telegram_message.txt)
- # Send message first
+ # Send message first (using HTML parse mode)
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHANNEL_ID}" \
-d text="${MESSAGE}" \
- -d parse_mode="Markdown" \
+ -d parse_mode="HTML" \
-d disable_web_page_preview="true"
# Upload arm64 APK to channel
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05962b4e..d57f4443 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,76 @@
# Changelog
+## [3.2.1] - 2026-01-22
+
+> **Note:** Next release will use `year.month.day` format (e.g., 26.2.1) and is scheduled for early February. Developer is taking a short break!
+
+### Added
+
+- **Artist/Album + Singles Folder Structure**: Singles go inside artist folder (`Artist/Album/`, `Artist/Singles/`)
+- **Embed Lyrics Button**: Manually embed online lyrics into tracks from Track Info screen (preserves synced timestamps)
+- **Pause/Resume Button**: Added pause and resume controls next to "Downloading" header in History screen
+- **Instrumental Detection**: Tracks marked as instrumental on lrclib.net now show "Instrumental track" instead of "Lyrics not available"
+
+### Fixed
+
+- **Lyrics**: Multi-artist tracks now search by primary artist first, then full string
+- **Lyrics**: Metadata tags (`[ti:...]`, `[ar:...]`, `[by:...]`) no longer shown in display
+- **Lyrics**: Embed button now correctly appears for tracks with online lyrics
+- **Lyrics**: Manual embed preserves original timestamps instead of plain text
+- **iOS**: Fixed "File not found" after 3.1.x → 3.2.0 update (container UUID migration)
+- **Home Feed**: Greeting now uses device local time
+- **Deezer**: Track position fallback to index+1 when API returns 0
+- **Localization**: Fixed 16 ICU plural syntax warnings in Spanish & Portuguese
+
+### Performance
+
+- **Home Feed**: Precomputed Quick Picks section flag and reduced per-page allocations; explore state now watched by field to cut rebuilds
+- **Home Recent**: Cached recent-access aggregation and limited list allocations for recent downloads
+- **Settings/Theme/Recent**: Cached SharedPreferences instance to avoid repeated `getInstance()` calls
+- **History/DB**: Batched iOS path migration updates to reduce write overhead
+- **Download Queue**: Reduced polling allocations and avoided double-load scheduling for history
+- **Misc**: Precompiled regex in share intent, update dialog, extensions error parsing, log analysis, and LRC cleanup; faster palette cache hits and log filtering
+
+---
+
+## [3.2.0] - 2026-01-22
+
+> **Note:** Starting from v3.2.0, changelogs will be concise.
+
+### Highlights
+
+- **Discography Download** (Highly Requested): Download entire artist discography with album selection mode
+- **Home Feed / Explore**: Personalized sections from spotify-web and ytmusic extensions
+- **SQLite History Database**: O(1) lookups, non-blocking writes
+
+### Added
+
+- Discography download with options: All, Albums Only, Singles Only, or Select Albums
+- Artist navigation from album screen (tap artist name)
+- Home feed sections with pull-to-refresh
+- YT Music Quick Picks swipeable UI
+- `gobackend.getLocalTime()` API for extensions
+- Track duration in home feed items
+- Release date badge in album info card
+
+### Improved
+
+- Album track list shows track number instead of cover image
+- Download buttons with more rounded corners
+- Downloaded songs in Recent show primary-colored subtitle
+
+### Fixed
+
+- Home feed timezone detection
+- Track duration 0:00 when downloading from home feed
+
+### Extensions
+
+- spotify-web v1.8.1: Home feed, artist_id support
+- ytmusic v1.6.1: Home feed, artist_id support
+
+---
+
## [3.1.3] - 2026-01-19
### Added
@@ -306,131 +377,22 @@
### Added
- **Recent Access History**: Quick access to recently visited content when tapping the search bar
- - Shows recently visited artists, albums, playlists, and downloaded tracks
- - Merged view combining navigation history and download history
- - Tap to quickly navigate back to previously accessed content
- - X button to remove individual items from history
- - "Clear All" button to clear entire history
- - Persists across app restarts (stored in SharedPreferences)
- - Max 20 items stored, sorted by most recent
- - Multi-language support (Artist/Album/Song/Playlist labels localized)
-
-- **Artist Screen Redesign**
- - Full-width header image (380px) with gradient overlay
- - Artist name displayed at bottom of header with text shadow
- - Monthly listeners count display (formatted with compact notation)
- - "Popular" section showing top 5 tracks with download status indicators
- - Dynamic download button states (queued, downloading, completed)
- - Header image and top tracks fetched from extension metadata
- - Image alignment set to top-center to show faces properly
-
-- **Extension Store Update Badge**: Badge indicator on Store tab icon showing number of available updates
- - Users can see extension updates are available without opening Store tab
- - Badge shows count of extensions with updates
-
-- **Extension Compatibility Warning**: Warning badge for extensions requiring newer app version
- - Extensions with `minAppVersion` higher than current app show warning label
- - Label displays "Requires vX.X.X+" to encourage users to upgrade
- - Users can still install the extension (not blocked)
-
-- **Year in Album Folder Name** ([#50](https://github.com/zarzet/SpotiFLAC-Mobile/issues/50)): New album folder structure options with release year
-
- - `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
- - `[Year] Album Only`: Albums/[2005] X&Y/
- - Year extracted from release date metadata
- - Matches desktop SpotiFLAC folder structure
-
-- **Extension Album/Playlist/Artist Support**: Extensions can now return albums, playlists, and artists in search results
-
- - Search results now properly separated into Albums, Playlists, Artists, and Songs sections
- - Albums, playlists, and artists show chevron icon (navigate to detail) instead of download button
- - Tap album/playlist to view track list and download
- - Tap artist to view their albums/discography
- - New `getAlbum()`, `getPlaylist()`, and `getArtist()` extension functions
- - New `ExtensionAlbumScreen`, `ExtensionPlaylistScreen`, and `ExtensionArtistScreen` for fetching content from extensions
- - YouTube Music extension updated with album/playlist/artist support
-
-- **Odesli (song.link) Integration for YouTube Music Extension**
- - New `enrichTrack()` function to fetch ISRC and external service links
- - Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz
- - Enables built-in service fallback for high-quality audio downloads
- - Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions
-- **Download Cancel**: Canceling a download now stops in-flight built-in provider downloads (Tidal/Qobuz/Amazon) and clears backend progress tracking.
+- **Artist Screen Redesign**: Full-width header, monthly listeners, top tracks section
+- **Extension Store Update Badge**: Badge indicator showing available extension updates
+- **Extension Compatibility Warning**: Warning for extensions requiring newer app version
+- **Year in Album Folder Name**: New folder structure options with release year
+- **Extension Album/Playlist/Artist Support**: Extensions can now return collections in search
+- **Odesli Integration**: YouTube Music extension can now match tracks to Deezer/Tidal/Qobuz
+- **Download Cancel**: Properly stops in-flight downloads
### Changed
-- **Search Bar Behavior**: Tapping search bar now immediately moves it to top position
- - Logo and subtitle hide when search bar is focused
- - Recent access history appears in the content area below
- - More space for recent items, not blocked by keyboard
+- Search bar behavior improved with recent access history
### Fixed
-- Fixed search source chips still referencing removed badge props.
-- Fixed extension artist album metadata to preserve provider IDs and cover URLs for correct navigation.
-- Fixed extension playlist fetch to populate provider IDs and reject disabled extensions.
-- Fixed extension collection screens calling setState after dispose during async loads.
-- Fixed URL handler responses to include provider IDs for extension albums and artists.
-- Fixed YTMusic extension not extracting album name and duration from search results.
- - Album name is now extracted from flexColumns/subtitle when linked to album browseId.
- - Duration is now extracted from fixedColumns/flexColumns in addition to existing sources.
-- Fixed "Separate Singles" setting not working ([#54](https://github.com/zarzet/SpotiFLAC-Mobile/issues/54)) - singles were going to Albums folder.
- - Root cause: `albumType` was not being extracted from Deezer API during metadata enrichment.
- - Deezer track responses now correctly include `album_type` (single/ep/album/compilation).
- - Track creation now preserves `albumType` and `source` fields throughout download flow.
-- Fixed PageView overscroll at edges (BouncingScrollPhysics → ClampingScrollPhysics)
-- Fixed settings item highlight on swipe (highlightColor: Colors.transparent)
-- Fixed extension duplicate load error (skip silently instead of throwing error)
-- Fixed keyboard appearing when swiping between tabs (unfocus on page change)
-- Removed "Free"/"API Key" badges from search source selector
-- Fixed cancel action briefly resuming downloads in the queue UI after ~1 second.
-- Fixed cancelled downloads being marked as failed when the backend returns after cancellation.
-- Fixed cancel triggering provider fallback (cancel now stops the download flow immediately).
-- Fixed stale ISRC cache returning deleted files after cancel.
-- Fixed search results mixing extension and built-in artists when using default provider.
-- Fixed audio files opening with non-music apps by passing audio MIME type on open.
-- Fixed album artist showing null/blank by normalizing empty metadata and using artist fallback for tags.
-- Fixed `use_build_context_synchronously` lint warnings in `home_tab.dart`
-- Fixed `unnecessary_underscores` lint warnings in error widget callbacks
-- Fixed duplicate artist entries in recent history (recording now only happens in screen's initState)
-- **Go Backend: Missing `item_type` and `album_type` fields**
- - Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct
- - Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response
- - Fixed `HandleURLWithExtensionJSON` - now includes `item_type` and `album_type` for tracks
- - Fixed `GetAlbumWithExtensionJSON` - now includes `item_type` and `album_type` for album tracks
- - Fixed `GetPlaylistWithExtensionJSON` - now includes `item_type` and `album_type` for playlist tracks
-- **Album/Playlist Track Thumbnails**: Tracks inside albums/playlists now use album/playlist cover as fallback when no individual cover exists
-- **YouTube Music Extension getArtist**: Fixed `getArtist()` function not being registered in extension, causing artist pages to fail with "returned null" error
-- **Recent Access UI**: Fixed recent access list disappearing when keyboard is dismissed - now stays visible until user presses Back button
-- **Extension Artist Top Tracks**: Fixed top tracks not appearing when opening artist from extension search results
- - 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 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, 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
-- **Web Extension**: Updated to v1.6.0
-
-### Localization
-
-- **Multi-Language Support**: App now supports multiple languages with community contributions via Crowdin
- - Available languages: English, Indonesian (Bahasa Indonesia)
- - More languages coming soon with community translations
- - Contribute translations at [Crowdin](https://crowdin.com/project/spotiflac-mobile)
-- Added new localization strings for recent access types:
- - `recentTypeArtist` - "Artist" / "Artis"
- - `recentTypeAlbum` - "Album" / "Album"
- - `recentTypeSong` - "Song" / "Lagu"
- - `recentTypePlaylist` - "Playlist" / "Playlist"
- - `recentPlaylistInfo` - "Playlist: {name}"
- - `errorGeneric` - "Error: {message}"
+- Multiple extension-related fixes for artist, album, and playlist handling
+- UI fixes for search, settings, and navigation
---
@@ -440,1541 +402,32 @@
SpotiFLAC 3.0 introduces a powerful extension system that allows third-party integrations for metadata, downloads, and more.
-#### Extension Store
-
-- Browse and install extensions directly from the app
-- New "Store" tab in bottom navigation
-- Browse by category: Metadata, Download, Utility, Lyrics, Integration
-- Search extensions by name, description, or tags
-- One-tap install, update, and uninstall
-- Offline cache for browsing without internet
-
-#### Web Extension
-
-- Available in Extension Store - install and enable in Settings > Extensions
-- Metadata provider using web player API
-- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
-- Useful when official API is rate-limited or unavailable
-
-#### Extension Capabilities
-
-- **Custom Search Providers**
-- **Custom URL Handlers**
-- **Custom Thumbnail Ratios**: Square (1:1), Wide (16:9), Portrait (2:3)
-- **Post-Processing Hooks**: Extensions can process downloaded files
-- **Quality Options**: Extensions can define custom quality settings
-
-#### Extension APIs
-
-- Full HTTP support: GET, POST, PUT, DELETE, PATCH
-- Persistent cookie jar per extension
-- Browser-like polyfills: `fetch()`, `atob()`/`btoa()`, `TextEncoder`/`TextDecoder`, `URL`/`URLSearchParams`
-- Storage API for persistent data
-- File API for file operations
-- HMAC-SHA1 utility for cryptographic operations
-
-#### Security
-
-- Sandboxed JavaScript runtime (goja)
-- Permission-based access control
-- Network domain whitelisting
-- Improved credential encryption with per-installation random salt
-
-### Added
-
-- **Album Folder Structure Setting**: Option to remove artist folder from album path
-
- - `Artist / Album` (default): `Albums/Artist Name/Album Name/`
- - `Album Only`: `Albums/Album Name/`
-
-- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
-
- - 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
-
- - `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
- - `[Year] Album Only`: Albums/[2005] X&Y/
- - Year extracted from release date metadata
- - Matches desktop SpotiFLAC folder structure
-
-- **Parallel API Calls**: Download URL fetching now uses parallel requests
- - Tidal: All 8 APIs requested simultaneously, first success wins
- - Qobuz: Both APIs requested simultaneously, first success wins
- - Significantly reduces download URL fetch time
-
-### UI/UX Improvements
-
-- **Swipeable History Filters**: History tab now supports swipe gestures between All, Albums, and Singles filters
-
- - Swipe left/right to switch between filter tabs
- - Filter chips sync with swipe position
- - Smooth edge-to-edge transition: swipe past Singles to go to Store, swipe past All to go to Home
- - Natural gesture feel - drag connects to parent navigation
-
- - **Improved File Open Intent**: Play button in History now correctly opens music players only
- - Added proper MIME type (`audio/flac`, `audio/mpeg`, etc.) when opening downloaded files
- - Prevents system from showing unrelated apps in the "Open with" dialog
-
-### Fixed
-
-- **Fixed Tab Edge Overscroll**: Home and Settings tabs now stop at edges instead of bouncing into empty space
-
-- **Fixed Extension Duplicate Load Error**: Extension loading now silently skips already-loaded extensions instead of throwing error
-
-- **Fixed Settings Item Highlight on Swipe**: Settings items no longer highlight when swiping at page edge
-
-- **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 provider selector in Options
-
-- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
-
- - Added `PopScope` with `canPop: true` to all settings pages
- - Changed navigation to use `PageRouteBuilder` with proper slide transition
-
-- **Bottom Overflow in Folder Organization Dialog**: Fixed overflow in portrait and landscape mode
-
- - Made dialog scrollable with max height constraint
-
-- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names
-
- - "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
-
-- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
-
- - "RADWIMPS feat. Toko Miura" now matches when service only shows "Toko Miura"
-
-- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
-
- - Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
-
-- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen
-
- - Duplicate detection prefix now stripped before saving to history
-
-- **Extension Search Result Parsing**: Fixed "cannot unmarshal array" error
-
- - Go backend now handles both array and object formats from extensions
-
-- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
-
-- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
-
- - Detects existing entries by track ID, Deezer ID, or ISRC
-
-- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
-
- - Now shows proper message: "Cannot write to folder, check storage permission"
-
-- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
- - Now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
-
-### Changed
-
-- **Extension Manifest**: New `file` permission required for file operations
- ```json
- "permissions": {
- "network": ["api.example.com"],
- "storage": true,
- "file": true
- }
- ```
-
-### Technical
-
-- Go backend: Simplified parallel download result handling in Tidal/Qobuz
-- Go backend: Removed unused functions and fixed bit shifting warnings
-- Release workflow: Fixed duplicate `---` separator in release notes
-
----
-
-## [3.0.0-beta.2] - 2026-01-13
-
-### Added
-
-- **Album Folder Structure Setting**: Option to remove artist folder from album path
- - New setting in Download Settings when "Separate Singles Folder" is enabled
- - `Artist / Album` (default): `Albums/Artist Name/Album Name/`
- - `Album Only`: `Albums/Album Name/`
- - Requested by user who prefers flat album organization
-
-### Fixed
-
-- **Back Gesture Freeze on OnePlus/Android 13+**: Fixed app freeze when using back gesture in settings
-
- - Added `PopScope` with `canPop: true` to all settings pages
- - Changed navigation to use `PageRouteBuilder` with proper slide transition
- - Fixes predictive back gesture conflict on devices with gesture navigation
- - Affected pages: Download, Appearance, Options, Extensions, About, Logs, Extension Detail
-
-- **Extension Search Result Parsing**: Fixed "cannot unmarshal array into Go value" error
-
- - Go backend now handles both array and object formats from extensions
- - Extensions returning `[{track}, {track}]` now work correctly
- - Extensions returning `{tracks: [...], total: N}` still work as before
-
-- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
-
- - Added missing `spotifySize300` constant (300x300 size code)
- - Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
- - Added `_upgradeToMaxQualityCover()` helper in Flutter for M4A conversion path
- - Go backend `cover.go` now directly replaces URL without HEAD verification
-
-- **Extension Search Provider Reset**: Fixed search provider not resetting to default when disabled
-
- - `copyWith` in `AppSettings` couldn't set `searchProvider` to `null`
- - Added `clearSearchProvider` boolean parameter to properly clear the value
- - Settings menu now correctly switches back to default provider
-
-- **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 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
-
- - Added `mounted` check after async operation in `_initialize()`
- - Prevents crash when navigating away from Store tab during initialization
-
-- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download
-
- - Duplicate detection was adding `EXISTS:` prefix to file paths
- - Prefix now stripped before saving to download history
- - Legacy history items with prefix are handled gracefully
-
-- **History Error Badge**: Fixed error badge showing on history items even when file exists
-
- - `queue_tab.dart` now strips `EXISTS:` prefix before checking file existence
- - File open and delete operations also use cleaned path
-
-- **Extension Artist URL Handler**: Fixed artist pages showing "0 releases" from extensions
-
- - Extension `fetchArtist` now returns correct format: `{ type: "artist", artist: { albums } }`
- - Go backend `HandleURLWithExtensionJSON` now includes albums in artist response
- - Added `AlbumType` field to `ExtAlbumMetadata` struct
-
-- **Extension Artist Name in Logs**: Fixed empty artist name in extension track logs
-
- - Now uses `firstArtist` + `otherArtists` instead of deprecated `artists.items`
- - Logs correctly show "Fetched track: {title} by {artist}"
-
-- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names with different order
-
- - "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
- - Added `sameWordsUnordered` check to both Tidal and Qobuz artist matching
- - Handles Japanese name order (family name first) vs Western name order (given name first)
-
-- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
-
- - "RADWIMPS feat. Toko Miura" now matches when Qobuz/Tidal only shows "Toko Miura"
- - Split artists by separators (`, `, `feat.`, `ft.`, `&`, `and`, `x`)
- - Match if ANY expected artist matches ANY found artist
-
-- **Cover Download Logging**: Improved cover download logs for debugging
- - Shows original URL, upgrade steps, and final URL
- - Displays estimated resolution based on file size
- - Logs now appear in Settings > Logs via GoLog
-
----
-
-## [3.0.0-beta.1] - 2026-01-13
-
-### Security
-
-- Improved extension sandbox security
-- Improved credential encryption with per-installation random salt
-
-### Changed
-
-- **Extension Manifest**: New `file` permission required for file operations
- ```json
- "permissions": {
- "network": ["api.example.com"],
- "storage": true,
- "file": true
- }
- ```
- Extensions that need to download files must declare `"file": true` in manifest.
-
-### Fixed
-
-- Extension packages now preserve directory structure (subdirectories supported)
-- Back gesture freeze in settings pages on Android gesture navigation
-
----
-
-## [3.0.0-alpha.4] - 2026-01-12
-
-### Added
-
- **Extension Store**: Browse and install extensions directly from the app
+- **Web Extension**: Metadata provider for personalized playlists
+- **Extension Capabilities**: Custom search, URL handlers, thumbnail ratios, post-processing
+- **Extension APIs**: Full HTTP, storage, file, and crypto support
+- **Security**: Sandboxed JavaScript runtime with permission-based access
- - New "Store" tab in bottom navigation
- - Browse extensions by category (Metadata, Download, Utility, Lyrics, Integration)
- - Search extensions by name, description, or tags
- - One-tap install and update
- - Offline cache for browsing without internet
- - Extensions hosted at github.com/zarzet/SpotiFLAC-Extension
+### Added
-- **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns
-
- - Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc.
- - Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }`
- - Implement `handleUrl(url)` function in extension to parse and return track metadata
- - SpotiFLAC automatically routes matching URLs to the appropriate extension
- - Supports share intents and paste from clipboard
-
-- **Artist URL Handler Support**: Extensions can now return artist data from URL handlers
-
- - Added `type: "artist"` handling in track_provider.dart
- - Navigate to artist screen with albums list from extension
-
-- **HMAC-SHA1 Utility**: New `utils.hmacSHA1(key, message)` function for extensions
- - Enables TOTP generation and other cryptographic operations
- - Returns byte array for flexible use
+- Album folder structure settings
+- Separate singles folder option
+- Year in album folder name
+- Parallel API calls for faster downloads
+- Swipeable history filters
### Fixed
-- **Extension Store Refresh**: Store tab now properly refreshes after uninstalling an extension
- - "Installed" badge correctly updates to "Install" button
-
-### Documentation
-
-- Updated `docs/EXTENSION_DEVELOPMENT.md`:
- - Added Custom URL Handler section with examples
- - Added `handleUrl` function documentation
- - Added URL pattern examples for YouTube, SoundCloud, Bandcamp
- - Added `utils.hmacSHA1` documentation with TOTP example
-
-### Extensions
-
-- **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)
+- Tab edge overscroll
+- Extension duplicate load error
+- Settings item highlight on swipe
+- Back gesture freeze on Android 13+
+- Bottom overflow in dialogs
+- Japanese artist name matching
+- Multi-artist matching
+- Max resolution cover download
+- Various extension-related fixes
---
-## [3.0.0-alpha.3] - 2026-01-12
-
-### Added
-
-- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
- - 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
- - `fetch()` - Browser-compatible HTTP API with `json()`, `text()`, `arrayBuffer()` methods
- - `atob()` / `btoa()` - Global Base64 encoding/decoding
- - `TextEncoder` / `TextDecoder` - UTF-8 text encoding classes
- - `URL` / `URLSearchParams` - URL parsing and manipulation classes
- - Makes porting browser libraries (like `youtubei.js`) much easier
-
-### Performance
-
-- **Parallel API Calls**: Download URL fetching now uses parallel requests
- - Tidal: All 8 APIs requested simultaneously, first success wins
- - Qobuz: Both APIs requested simultaneously, first success wins
- - Significantly reduces download URL fetch time
-
-### Fixed
-
-- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- - 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
- - Now checks if extension is still enabled before calling custom search
- - Auto-resets search provider to default if extension was disabled
-- **Permission Error Message**: Fixed download showing "Song not found" when actually a permission error
- - Now shows proper message: "Cannot write to folder, check storage permission"
- - Added `permission` error type detection in backend
-- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
- - Android 13+ now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
- - `MANAGE_EXTERNAL_STORAGE` opens Settings (system-level, persists across app data clear)
- - `READ_MEDIA_AUDIO` shows dialog (app-level, resets on app data clear)
- - Proper permission check before showing "granted" status
-
----
-
-## [3.0.0-alpha.2] - 2026-01-12
-
-### Added
-
-- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs
- - `http.put(url, body, headers)` - PUT requests
- - `http.delete(url, headers)` - DELETE requests
- - `http.patch(url, body, headers)` - PATCH requests
- - `http.clearCookies()` - Clear all cookies for the extension
-- **Persistent Cookie Jar**: Each extension now has its own cookie jar
- - Cookies automatically stored from `Set-Cookie` headers
- - Cookies automatically sent with subsequent requests to same domain
- - Useful for APIs requiring session cookies (YouTube, etc.)
-- **Multi-Value Header Support**: Response headers now return arrays for multi-value headers
- - `Set-Cookie` and other headers with multiple values returned as arrays
- - Single-value headers still returned as strings for convenience
-- **Generic HTTP Request Method**: New `http.request()` for full HTTP control
- - Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
- - Single options object for cleaner API: `http.request(url, { method, body, headers })`
-- **Response Helper Properties**: HTTP responses now include convenience properties
- - `response.ok` - true if status code is 2xx
- - `response.status` - alias for `statusCode`
-
-### Fixed
-
-- **User-Agent Header Respect**: Custom `User-Agent` headers are now respected
- - Previously, extension-provided User-Agent was overwritten
- - Now only sets default User-Agent if extension doesn't provide one
-- **HTTP POST Body Auto-Stringify**: `http.post()` now automatically stringifies objects to JSON
- - Previously, passing an object as body resulted in `[object Object]`
- - Now objects and arrays are automatically JSON.stringify'd
- - String bodies still work as before (no double-encoding)
-
-### Documentation
-
-- Updated `docs/EXTENSION_DEVELOPMENT.md`:
- - Added complete HTTP API documentation with all methods
- - Added Cookie Jar documentation
- - Added `http.put()`, `http.delete()`, `http.patch()`, `http.clearCookies()` docs
- - Added YouTube Music / Innertube API example with custom User-Agent
- - Added common domain lists for YouTube, SoundCloud, Bandcamp
- - Improved HTTP API documentation with response properties
-
----
-
-## [3.0.0-alpha.1] - 2026-01-11
-
-#### Extension System
-
-- **Custom Search Providers**: Extensions can now provide custom search functionality
- - YouTube, SoundCloud, and other platforms via extensions
- - Custom search placeholder text per extension
- - Configurable thumbnail aspect ratios (square, wide, portrait)
-- **Extension Upgrade System**: Upgrade extensions without losing data
- - Preserves extension settings and cached data during upgrades
- - Version comparison prevents downgrades
- - Auto-detects upgrades when installing same extension
-- **Custom Thumbnail Ratios**: Extensions can specify thumbnail display format
- - `"square"` (1:1) - Album art style (default)
- - `"wide"` (16:9) - YouTube/video style
- - `"portrait"` (2:3) - Poster style
- - Custom width/height override available
-
-### Added
-
-- **Track Source Tracking**: Tracks now remember which extension provided them
- - `Track.source` field stores extension ID
- - `TrackState.searchExtensionId` for current search context
- - Enables extension-specific UI customization
-- **Extension Upgrade API**: New methods for extension management
- - `upgradeExtension(filePath)` - Upgrade existing extension
- - `checkExtensionUpgrade(filePath)` - Check if file is an upgrade
- - `RemoveExtensionByID` - Remove extension by ID
-- **iOS Extension Support**: Added missing iOS method handlers
- - `upgradeExtension` - Upgrade extension from file
- - `checkExtensionUpgrade` - Check upgrade compatibility
-- **Extension Documentation**: Comprehensive extension development guide
- - Thumbnail ratio customization documentation
- - Extension upgrade workflow documentation
- - New troubleshooting entries for common issues
-
-### Changed
-
-- **Version Bump**: 2.2.7 → 3.0.0-alpha.1 (major version for extension system)
-- **Build Number**: 49 → 50
-- **Extension Manager**: Improved upgrade detection in `LoadExtensionFromFile`
- - Auto-detects if installing same extension with higher version
- - Calls `UpgradeExtension` automatically for seamless upgrades
-
-### Fixed
-
-- **Extension `registerExtension`**: Fixed global `extension` variable not being set
- - Extensions can now access their own functions via `extension.functionName()`
- - Required for `customSearch` and other provider functions
-- **Custom Search Empty Results**: Fixed error when extension returns null
- - Now returns empty array instead of error
- - Prevents crash when no results found
-- **Mutex Crash on Upgrade**: Fixed "Unlock of unlocked RWMutex" crash
- - Removed `defer m.mu.Unlock()` when manual unlock is used
- - Proper lock handling in upgrade flow
-- **Duplicate Error Messages**: Fixed extension install errors showing twice
- - Added `clearError()` method to extension provider
- - Improved PlatformException parsing to remove "null, null" artifacts
-- **Extension Images Field**: Fixed thumbnails not showing in search results
- - Added `Images` field to `ExtTrackMetadata` struct
- - Renamed `GetCoverURL` to `ResolvedCoverURL` (gomobile conflict)
-
-### Technical
-
-- **Go Backend Changes**:
- - `go_backend/extension_manager.go`: Added `compareVersions()`, `UpgradeExtension()`, `CheckExtensionUpgradeJSON()`
- - `go_backend/extension_providers.go`: Added `Images` field, `ResolvedCoverURL()` method
- - `go_backend/extension_manifest.go`: Added `ThumbnailRatio`, `ThumbnailWidth`, `ThumbnailHeight` to `SearchBehaviorConfig`
- - `go_backend/exports.go`: Added `RemoveExtensionByID`, `UpgradeExtensionFromPath`, `CheckExtensionUpgradeFromPath`
-- **Flutter Changes**:
- - `lib/models/track.dart`: Added `source` field
- - `lib/models/track.g.dart`: Updated for `source` field
- - `lib/providers/track_provider.dart`: Added `searchExtensionId`, updated `_parseSearchTrack` with source parameter
- - `lib/providers/extension_provider.dart`: Added `SearchBehavior.getThumbnailSize()`, `clearError()`
- - `lib/screens/home_tab.dart`: Dynamic thumbnail size based on extension config
- - `lib/screens/settings/extensions_page.dart`: Improved error handling
- - `lib/services/platform_bridge.dart`: Added `upgradeExtension()`, `checkExtensionUpgrade()`, `removeExtension()`
-- **iOS Changes**:
- - `ios/Runner/AppDelegate.swift`: Added `upgradeExtension`, `checkExtensionUpgrade` handlers
-- **Android Changes**:
- - `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods
-
----
-
-## [2.2.8] - 2026-01-12
-
-### Added
-
-- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode
- - Select multiple tracks at once
- - "Select All" and "Delete Selected" actions
- - Modern Material 3 bottom action bar (slides up from bottom)
- - Works in both grid and list view modes
-- **History Filter Tabs**: Filter history by All/Albums/Singles
- - Album = tracks where album has >1 track in history
- - Single = tracks where album has only 1 track in history
- - Filter chips show counts for each category
-- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album
- - Album cards displayed in 2-column grid with cover art and track count badge
- - Tap album to open dedicated album detail screen
- - Album detail shows all downloaded tracks from that album
- - Multi-select delete support within album view
- - Auto-navigates back when album has <2 tracks remaining
-
-### Changed
-
-- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
-
----
-
-## [2.2.7] - 2026-01-11
-
-### Added
-
-- **CSV Import Metadata Enrichment**: Tracks imported from CSV now automatically fetch metadata from Deezer
- - Cover art, duration, track/disc number fetched via ISRC lookup
- - Fallback to text search (artist + track name) when ISRC not found in Deezer
- - Progress dialog shows enrichment status during import
- - Ensures downloaded files have proper cover art and metadata
-- **Deezer Metadata Support**: Enhanced metadata viewer for Deezer tracks
- - "Open in Deezer" button for Deezer-sourced tracks (opens app or web)
- - Displays "Deezer ID" instead of "Spotify ID" when applicable
-- **Smart Tag Injection**: Filename format editor intelligently handles separators
- - Auto-detects if " - " is needed between tags
- - Prevents double separators or missing spaces
-- **Dynamic Source Info**: Search source selector now shows helpful context
- - "No login required" for Deezer
- - "Requires credentials" for Spotify
-
-### Changed
-
-- **UI Modernization**: Major UI consistency updates across the app
- - **Unified App Bars**: Home, History, and Settings now share identical behavior
- - Lowered expanded header for easier one-handed reachability
- - Dynamic title text scaling (20px to 34px)
- - **Appearance Settings**: Completely redesigned appearance page
- - New "Theme Preview" card showing visualizing current theme
- - Modern color palette picker replacing old color dots
- - Clean, grouped layout
- - "AMOLED Dark" switch is now hidden when using Light Mode
- - **App Logo**: Refined logo style on Home and About screens
- - Inverted colors: Filled primary color circle with on-color icon
- - Removed padding for a cleaner, bolder look
- - **Material 3 Switches**: Added checkmark icon to active switches
-- **UI Modernization (Global)**: Complete design refresh for a cleaner, modern look
- - **Rounded Corners**: Standardized 16px radius for all cards, buttons, and input fields
- - **Transparent Elements**: Applied subtle transparency to input fields and containers using `surfaceContainerHighest`
- - **Consistent Buttons**: Unified button styling across the app (pill shape, 16px radius)
-- **Options Settings Redesign**: improved layout and usability
- - **Search Source Priority**: Moved "Search Source" section to the very top for quick access
- - **Compact Source Selector**: Redesigned provider toggle (Deezer/Spotify) to be compact and consistent
- - **Credentials Workflow**: Reorganized Custom Credentials settings; toggle now auto-prompts if credentials missing
- - **Modern Credentials Dialog**: Totally redesigned input dialog for Spotify Client ID/Secret
-- **Filename Format Editor 2.0**:
- - **Modern Sheet UI**: Replaced legacy dialog with a clean, full-width bottom sheet
- - **Tag Chips**: Added clickable chips ({artist}, {title}) for one-tap insertion
- - **Smart Formatting**: Automatically injects separators (" - ") when adding tags for faster editing
-
-### Fixed
-
-- **CSV Import Missing Cover Art**: Fixed tracks from CSV having no cover art in download history
- - Cover URL now properly fetched from Deezer during enrichment
- - Falls back to text search when ISRC lookup fails
-- **CSV Import Missing Duration**: Fixed duration showing 0:00 for CSV-imported tracks
- - Duration now fetched from Deezer metadata during enrichment
-- **Disc Number Not Displayed**: Fixed disc number not showing in track metadata screen
- - Changed condition from `discNumber > 0` to `discNumber > 0`
- - Now displays disc 1 instead of hiding it
-- **Download History Using Wrong Track Data**: Fixed history using original CSV data instead of enriched data
- - Now uses `trackToDownload` (enriched) instead of `item.track` (original)
-
-### Technical
-
-- Updated `lib/services/csv_import_service.dart`:
- - Added `_enrichTracksMetadata()` with ISRC lookup + text search fallback
- - Added progress callback for UI feedback
-- Updated `lib/screens/home_tab.dart`:
- - Added progress dialog during CSV enrichment
-- Updated `lib/providers/download_queue_provider.dart`:
- - Uses enriched track data for download history
-- Updated `lib/screens/track_metadata_screen.dart`:
- - Show disc number when > 0 (was > 1)
-- Updated `go_backend/metadata.go`:
- - Added `TotalSamples` to `AudioQuality` struct for duration calculation
-- Updated `go_backend/exports.go`:
- - `ReadFileMetadata` now returns duration calculated from FLAC stream info
-- Updated `AppTheme` with new `InputDecorationTheme` and `ButtonTheme` definitions
-- Refactored `DownloadSettingsPage` to use new `_showFormatEditor` with cursor-aware capabilities
-- Optimized various dialogs to use `showModalBottomSheet` with `isScrollControlled` for better keyboard handling
-
----
-
-## [2.2.6] - 2026-01-11
-
-### Fixed
-
-- **Release Mode Logging**: Flutter app logs now properly captured in release builds
- - Previously only Go backend logs appeared when "Detailed Logging" was enabled
- - Now both Flutter and Go logs are captured in release mode
- - Bypasses Logger package which filters logs in release mode
-
-### Added
-
-- **Detailed Deezer Search Logging**: Better debugging for search issues
- - Logs API URLs, response counts, and errors
- - Helps diagnose geo-restriction and API issues
- - Detects Deezer API error responses
-
-### Changed
-
-- **Home Screen Logo**: Replaced music note icon with app logo
- - Uses `assets/images/logo.png`
- - Rounded corners (24px radius)
- - Fallback to music note icon if logo fails to load
-- **About Page Logo**: Removed shadow/border from logo
- - Cleaner appearance without background container
-- **About Page Icon Alignment**: Icons now aligned with contributor avatars
- - DoubleDouble and DAB Music icons use 40x40 area
- - Text now properly aligned with contributor items
-
-## [2.2.5] - 2026-01-10
-
-### Added
-
-- **In-App Log Viewer with Go Backend Logs**: Complete logging system for debugging
- - Go backend logs now captured and displayed in app
- - Circular buffer stores up to 500 log entries
- - Real-time polling (500ms) for Go backend logs
- - Logs include timestamp, level, tag, and message
- - "Go" badge indicates logs from backend
-- **Detailed Logging Toggle**: Control logging in Settings > Options > Debug
- - Disabled by default for performance
- - Errors are always logged regardless of setting
- - Enable before reproducing bugs for detailed logs
-- **Log Issue Summary**: Automatic detection of common issues in logs
- - ISP Blocking detection with affected domains
- - Rate limiting detection
- - Network error detection
- - Track not found detection
- - Shows suggestions for each issue type
-- **ISP Blocking Detection**: Detects when ISP blocks download services
- - DNS resolution failure detection
- - Connection reset/refused detection
- - TLS handshake failure detection
- - HTTP 403/451 blocking page detection
- - Suggests VPN or DNS change (1.1.1.1 / 8.8.8.8)
-
-### Fixed
-
-- **Artist Profile Placeholder**: Shows person icon when artist has no profile image
- - Validates image URL before loading
- - Fallback icon on load error
-- **Latin Extended Character Detection**: Fixed wrong track downloads for Polish, Czech, French, Spanish songs
- - Characters like Ł, ę, ć, ñ, é now correctly treated as Latin script
- - Previously treated as "different script" causing false matches
- - Affects both Tidal and Qobuz search
-
-### Changed
-
-- **Log Screen UI Improvements**:
- - Copy button moved to app bar (left of menu)
- - Removed redundant info card
- - Cleaner interface
-- **Issue Templates Updated**: Instructions for enabling detailed logging before submitting bug reports
-
-### Technical
-
-- New file: `go_backend/logbuffer.go` with circular buffer and GoLog function
-- Updated `go_backend/httputil.go` with ISP blocking detection
-- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with `isLatinScript()` function
-- Updated `lib/utils/logger.dart` with Go log polling
-- Updated `lib/screens/settings/log_screen.dart` with issue summary
-- Added method channel handlers for logging in Android and iOS
-- New error type: `isp_blocked` for ISP blocking errors
-
----
-
-## [2.2.0] - 2026-01-10
-
-### Fixed
-
-- **ISRC Metadata Missing:** Fixed an issue where ISRC codes were not being saved to the download history or embedded in file metadata for certain downloads. The backend now correctly propagates the ISRC found from streaming services (Tidal, Qobuz, Amazon) back to the application.
-- **Tidal Track/Disc Numbers:** Fixed missing Track Number and Disc Number in Tidal downloads. The downloader now prioritizes the actual metadata returned by Tidal over the potentially incomplete metadata from the initial search request.
-- **Concurrent Download Race Condition:** Fixed a potential race condition where temporary cover art files could overwrite each other during rapid concurrent downloads by adding randomization to temporary filenames.
-- **Qobuz Search Accuracy:** Reduced the duration tolerance for Qobuz search matches from 30s to 10s to prevent matching with incorrect versions/remixes.
-- **Metadata Enrichment Null Safety**: Fixed `type 'Null' is not a subtype of type 'String'` error
- - Added proper null checks when parsing Go backend response
- - Added type checking for track data before parsing
-- **Duration Calculation in Enrichment**: Fixed duration conversion bug
- - Go backend returns `duration_ms` (milliseconds)
- - Now properly converts to seconds for Track model
-
-### Changed
-
-- **Default Service Priority:** Updated the default download fallback order to **Tidal → Qobuz → Amazon**.
- - Tidal is now the default download service (was Qobuz)
- - Tidal has faster and more reliable ISRC matching
- - Existing users need to change setting manually or clear app data
-- **Metadata Enrichment:** Improved metadata handling for Deezer tracks. If critical metadata (ISRC, Track Number) is missing from the initial search, the app now automatically fetches full details from the Deezer API before finding a source.
-
-### Added
-
-- **ISRC in History:** The Download History now reliably displays the ISRC code for downloaded tracks.
-- **Tidal Search Optimization:** Optimized Tidal search logic to immediately check for ISRC matches within search results, improving match speed and accuracy.
- - Returns as soon as ISRC match is found in first query results
- - Significantly faster for tracks with valid ISRC
-- **ISRC Enrichment for Search Results**: Tracks from Home search now fetch ISRC before download
- - Search results don't include ISRC (for performance)
- - ISRC is now fetched via metadata enrichment when download starts
- - Ensures accurate track matching on all streaming services
-- **Deezer-to-Tidal Fallback:** Added native support for converting Deezer IDs to Tidal links via SongLink when using the fallback mechanism.
-- **Better Logging for Qobuz ISRC Search**: Added detailed logs for debugging
- - Shows when ISRC search is attempted
- - Shows number of results and exact ISRC matches found
-
-### Technical
-
-- Updated `go_backend/tidal.go`:
- - Early exit optimization in `SearchTrackByMetadataWithISRC()`
- - Deezer ID support in SongLink lookup
-- Updated `go_backend/qobuz.go`:
- - Added logging for ISRC search flow
- - Duration tolerance reduced from 30s to 10s
-- Updated `go_backend/exports.go`:
- - Default service order changed to `[tidal, qobuz, amazon]`
-- Updated `lib/providers/download_queue_provider.dart`:
- - ISRC-based enrichment condition
- - Null-safe parsing of Go backend response
-- Updated `lib/services/platform_bridge.dart`:
- - Null check for `getDeezerMetadata` result
-- Updated `lib/models/settings.dart`:
- - Default service changed to `tidal`
-
----
-
-## [2.1.7] - 2026-01-09
-
-### Added
-
-- **Special Thanks Section**: Added new "Special Thanks" section in About page to credit API creators
- - **uimaxbai** - Creator of QQDL & HiFi API for Tidal downloads
- - **sachinsenal0x64** - Original HiFi project creator, foundation of Tidal integration
- - **DoubleDouble** - Amazing API for Amazon Music downloads
- - **DAB Music** - The best Qobuz streaming API for Hi-Res downloads
-- **New Contributor**: Added Amonoman to Contributors section as the app logo creator
-
-### Fixed
-
-- **Missing PlatformBridge Import**: Fixed build errors in `home_tab.dart` and `playlist_screen.dart`
- - Added missing `import 'package:spotiflac_android/services/platform_bridge.dart'`
-- **iOS Method Channel Crash**: Fixed "Method not implemented" crash when searching Deezer from iOS
- - Implemented missing `searchDeezerAll` handler in `AppDelegate.swift`
- - Ensures full compatibility with new Deezer integration features on iOS
-
----
-
-## [2.1.6] - 2026-01-08
-
-### Added
-
-- **Metadata Enrichment**: Automatically fetches full track details if metadata is incomplete (e.g., Track Number 0)
- - Fixes missing Track Number, Disc Number, and Year for tracks added from Search results
- - Ensures accurate tagging for Deezer/Tidal downloads
-- **ISRC Index Building**: Fast duplicate checking with cached ISRC index
-
- - Scans download folder once and builds index of all ISRCs
- - 5 minute cache TTL for optimal performance
- - Parallel duplicate checking for album/playlist tracks
- - Auto-adds new downloads to index (no rebuild needed)
-
-- **Japanese to Romaji Search**: Better search results for Japanese tracks
-
- - Converts Hiragana/Katakana to Romaji for Tidal/Qobuz search
- - 4 fallback search strategies (like PC version):
- 1. Original text (artist + track)
- 2. Romaji converted (artist + track)
- 3. ASCII-only cleaned version
- 4. Artist name only as last resort
- - Handles combination characters (きゃ →kya, シャ →sha, etc.)
-
-- **SongLink Deezer Support**: Query SongLink using Deezer ID as source
-
- - `CheckAvailabilityFromDeezer()` - find track on other platforms using Deezer ID
- - `CheckAvailabilityByPlatform()` - generic function for any platform
- - `GetSpotifyIDFromDeezer()`, `GetTidalURLFromDeezer()`, `GetAmazonURLFromDeezer()`
- - Useful when starting from Deezer metadata
-
-- **LRC Metadata Headers**: Lyrics now include metadata headers
-
- - `[ti:Track Name]` - track title
- - `[ar:Artist Name]` - artist name
- - `[by:SpotiFLAC-Mobile]` - generator tag
-
-- **Download Error Types**: Better error categorization for UI
-
- - `not_found` - track not available on any service
- - `rate_limit` - API rate limit exceeded
- - `network` - connection/timeout errors
- - `unknown` - other errors
-
-- **Amazon Rate Limiting**: Proper rate limiting for Amazon via SongLink
- - 7 second minimum delay between requests
- - Max 9 requests per minute
- - 3x retry with 15s wait on 429 rate limit
-
-### Fixed
-
-- **SongLink 400 Error**: Added validation for empty Spotify ID
-
- - Specific error messages for 400, 404, 429 status codes
- - Better error handling for invalid track IDs
-
-- **gomobile Compatibility**: Fixed `ISRCIndex.Lookup()` signature
- - Changed from `(string, bool)` to `(string, error)` for gomobile binding
-
-### Technical
-
-- New file: `go_backend/romaji.go` with Japanese to Romaji conversion
-- New file: `go_backend/duplicate.go` with ISRC index building
-- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with romaji search strategies
-- Updated `go_backend/songlink.go` with Deezer support functions
-- Updated `go_backend/exports.go` with new export functions for Flutter
-- Updated `go_backend/lyrics.go` with `convertToLRCWithMetadata()`
-- Updated `go_backend/progress.go` with `SpeedMBps` field
-- Updated `lib/models/download_item.dart` with `DownloadErrorType` enum
-- Updated `lib/screens/queue_tab.dart` with speed display and error messages
-
----
-
-## [2.1.6-preview] - 2026-01-08
-
-### Added
-
-- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search
-
- - Configure in Settings > Options > Spotify API > Search Source
- - Default is Deezer for better reliability
- - Spotify URLs are always supported regardless of this setting
-
-- **Automatic Deezer Fallback for Spotify URLs**: When Spotify API is rate limited (429), automatically falls back to Deezer
- - Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID
- - Fetches metadata from Deezer instead
-
-### Changed
-
-- **Default Download Service**: Changed from Tidal to Qobuz
- - Fallback order is now: Qobuz → Tidal → Amazon
-- **Deezer API Updated to v2.0**: More reliable and complete metadata
- - Direct ISRC lookup via `/track/isrc:{ISRC}` endpoint
- - Search results now fetch full track info to include ISRC
-
-### Fixed
-
-- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
- - Progress now updates smoothly every 64KB of data received
- - First progress update happens immediately when download starts
-- **Incomplete Downloads**: Fixed bug where interrupted downloads could result in corrupted/incomplete files
- - File size is validated against server's Content-Length header
- - Incomplete files are automatically deleted and error is reported
- - Applies to all services: Tidal, Qobuz, and Amazon
-- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
-
-### Technical
-
-- Settings migration for existing users to set Deezer as default metadata source
-
----
-
-## [2.1.5] - 2026-01-08
-
-### Added
-
-- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- - Service selector chips appear above quality options
- - Defaults to your preferred service from settings
- - Change service on-the-fly without going to settings
- - Available in Home, Album, and Playlist screens
-- **AMOLED Dark Theme**: Pure black background for OLED screens
- - Toggle in Settings > Appearance > Theme
- - Saves battery on OLED/AMOLED displays
- - All surface colors adjusted for true black background
-- **Update Channel Setting**: Choose between Stable and Preview release channels
- - Stable: Only receive stable release notifications
- - Preview: Get notified about preview/beta releases too
- - Configure in Settings > Options > App
-
-### Changed
-
-- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- - arm64 APK: 46.6 MB (previously 51 MB)
- - arm32 APK: 59 MB (previously 64 MB)
- - Only includes FLAC, MP3 (LAME), and AAC codecs
- - Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
- - Native MethodChannel bridge for FFmpeg operations
- - Separate iOS build configuration with ffmpeg_kit_flutter plugin
-
-### Fixed
-
-- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- - Now properly handles retry when queue processing has finished
- - Also allows retrying skipped (cancelled) downloads
-- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
- - Shows "Lyrics not available" instead of loading forever
-- **iOS Directory Picker**: Fixed unable to select download folder on iOS
- - iOS limitation: Empty folders cannot be selected via document picker
- - Added "App Documents Folder" option as recommended default
- - Files saved to app Documents folder are accessible via iOS Files app
-
-### Performance
-
-- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- - Token caching for Tidal (eliminates redundant auth requests)
- - Singleton pattern for all downloaders (HTTP connection reuse)
- - ISRC search first strategy (faster than SongLink API)
- - Track ID cache with 30 minute TTL for album/playlist downloads
- - Pre-warm cache when viewing album/playlist
- - Parallel cover art and lyrics fetching during audio download
- - 64KB HTTP read/write buffers
- - 256KB buffered file writer for all downloaders
- - Progress updates every 64KB (reduced lock contention)
-- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
-
-## [2.1.0-preview2] - 2026-01-06
-
-### Added
-
-- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- - Service selector chips appear above quality options
- - Defaults to your preferred service from settings
- - Change service on-the-fly without going to settings
- - Available in Home, Album, and Playlist screens
-- **AMOLED Dark Theme**: Pure black background for OLED screens
- - Toggle in Settings > Appearance > Theme
- - Saves battery on OLED/AMOLED displays
- - All surface colors adjusted for true black background
-- **Update Channel Setting**: Choose between Stable and Preview release channels
- - Stable: Only receive stable release notifications
- - Preview: Get notified about preview/beta releases too
- - Configure in Settings > Options > App
-
-### Fixed
-
-- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- - Now properly handles retry when queue processing has finished
- - Also allows retrying skipped (cancelled) downloads
- - Added logging for better debugging
-- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
- - Shows "Lyrics not available" instead of loading forever
- - Better error messages for timeout and not found cases
-
-## [2.1.0-preview] - 2026-01-06
-
-### Performance
-
-- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- - Token caching for Tidal (eliminates redundant auth requests)
- - Singleton pattern for all downloaders (HTTP connection reuse)
- - ISRC search first strategy (faster than SongLink API)
- - Track ID cache with 30 minute TTL for album/playlist downloads
- - Pre-warm cache when viewing album/playlist
- - Parallel cover art and lyrics fetching during audio download
- - 64KB HTTP read/write buffers
- - 256KB buffered file writer for all downloaders
- - Progress updates every 64KB (reduced lock contention)
-- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
-
-### Technical
-
-- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
-- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
-- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
-
-## [2.0.7-preview2] - 2026-01-06
-
-### Fixed
-
-- **iOS Directory Picker**: Fixed unable to select download folder on iOS
- - iOS limitation: Empty folders cannot be selected via document picker
- - Added "App Documents Folder" option as recommended default
- - Shows info message explaining iOS limitation
- - Files saved to app Documents folder are accessible via iOS Files app
-
-## [2.0.7-preview] - 2026-01-05
-
-### Changed
-
-- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- - arm64 APK: 46.6 MB (previously 51 MB)
- - arm32 APK: 59 MB (previously 64 MB)
- - Only includes FLAC, MP3 (LAME), and AAC codecs
- - Removed x86/x86_64 architectures (emulator only)
-
-### Technical
-
-- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
-- Native MethodChannel bridge for FFmpeg operations
-- Separate iOS build configuration with ffmpeg_kit_flutter plugin
-
-## [2.0.6] - 2026-01-05
-
-### Fixed
-
-- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
- - `duration_ms` (milliseconds) was being stored directly without conversion to seconds
- - Now properly converts milliseconds to seconds before display
-- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API
- - More accurate quality display for all services (Tidal, Qobuz, Amazon)
- - Also reads quality from existing files when skipping duplicates
-- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks
- - Verifies artist matches between Spotify metadata and streaming service
- - Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration
- - Applied to Tidal, Qobuz, and Amazon downloads
-- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags
- - Now uses case-insensitive comparison when replacing existing Vorbis comments
- - Fixes issue where Amazon downloads could have duplicate metadata tags
-- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices
- - Added proper PopScope handling for predictive back gesture on Android 14+
-
-## [2.0.5] - 2026-01-05
-
-### Added
-
-- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
-
-### Fixed
-
-- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
-
-## [2.0.4] - 2026-01-04
-
-### Fixed
-
-- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
- - Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
- - Shows explanation dialog before opening system settings
-
-## [2.0.3] - 2026-01-03
-
-### Added
-
-- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
- - Toggle to enable/disable custom credentials without deleting them
- - Material Expressive 3 bottom sheet UI for entering credentials
-- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results
-- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
-
-### Changed
-
-- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
-
-### Fixed
-
-- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
-- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
-- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
-- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen
-- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded
-
-## [2.0.2] - 2026-01-03
-
-### Added
-
-- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
- - Quality badge on download history items (e.g., "24-bit", "16-bit")
- - Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
- - Tertiary color highlight for Hi-Res (24-bit) downloads
-- **Quality Disclaimer**: Added note in quality picker explaining that actual quality depends on track availability
-- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
-
-### Fixed
-
-- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
-- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
-
-### Removed
-
-- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
-
-### Technical
-
-- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
-- Go backend now returns `service` field indicating actual service used (important for fallback)
-- Tidal API v2 response provides exact quality info
-- Qobuz uses track metadata for quality info
-- Amazon now reads quality from downloaded FLAC file (previously returned unknown)
-
-## [2.0.1] - 2026-01-03
-
-### Added
-
-- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
- - Tap to expand long track titles
- - Expand icon only shows when title is truncated
- - Ripple effect follows rounded corners including drag handle
-
-### Changed
-
-- **Unified Progress Tracking System**: Deprecated legacy single-download progress
- - All downloads now use item-based progress tracking
- - Fixes duplicate notification bug when finalizing
- - Cleaner codebase with single progress system
-
-### Fixed
-
-- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
-- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
-- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
- - Container with `primaryContainer` background for each option
- - Distinct icons: music_note (Lossless), high_quality (Hi-Res), four_k (Max)
-
-## [2.0.0] - 2026-01-03
-
-### Added
-
-- **Artist Search Results**: Search now shows artists alongside tracks
- - Horizontal scrollable artist cards with circular avatars
- - Tap artist to view their discography
-- **Multi-Layer Caching System**: Aggressive caching to minimize API calls
- - Go backend cache: Artist (10 min), Album (10 min), Search (5 min)
- - Flutter memory cache: Instant navigation for previously viewed artists/albums
- - Duplicate search prevention: Same query won't trigger new API call
-- **Real-time Download Status**: Track items show live download progress
- - Queued: Hourglass icon
- - Downloading: Circular progress with percentage
- - Completed: Check icon
- - Works in Home search, Album, and Playlist screens
-- **Downloaded Track Indicator**: Tracks already in history show check mark
- - Lazy file verification: Only checks file existence when tapped
- - Auto-removes from history if file was deleted, allowing re-download
- - Prevents accidental duplicate downloads
-- **Pre-release Support**: GitHub Actions auto-detects preview/beta/rc/alpha tags
- - Stable users won't receive update notifications for preview versions
-
-### Changed
-
-- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
- - Header (name, cover) shows instantly from available data
- - Content (albums/tracks) loads in background inside the screen
- - Second visit to same artist/album is instant from Flutter cache
-- **Search Results UI Redesign**:
- - Removed "Download All" button from search results
- - Added "Songs" section header (matches "Artists" header style)
- - Track list now in grouped card with rounded corners (like Settings)
- - Track items with dividers and InkWell ripple effect
-- **Larger UI Elements**: Improved touch targets and visual hierarchy
- - Recent downloads: Album art 56→100px, section height 80→130px
- - Artist cards: Avatar 72→88px, container 90→100px
- - Track items: Album art 48→56px
-- **Optimized Search**: Pressing Enter with same query no longer triggers duplicate search
-- **Smoother Progress Animation**: Progress jumps to 100% after download completes
- - Embedding (cover, metadata, lyrics) happens in background without blocking UI
-- **Finalizing Status**: Shows "Finalizing" indicator while embedding metadata
- - Distinct icon (edit_note) with tertiary color
- - User knows download is complete, just processing metadata
-- **Consistent Download Button Sizes**: All download/status buttons now 44x44px
-- **Better Dynamic Color Contrast**: Improved visibility for cards and chips with dynamic color
- - Settings cards use overlay colors for better contrast
- - Theme/view mode chips have visible borders in light mode
-- **Navigation Bar Styling**: Distinct background color from content area
-- **Ask Before Download Default**: Now enabled by default for better UX
-
-### Fixed
-
-- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
-- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
-- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
- - Uses Riverpod `select()` for granular state watching
- - Prevents entire list rebuild on progress updates
-- **Update Notification Stuck**: Fixed notification staying at 100% after download complete
-
-## [1.6.3] - 2026-01-03
-
-### Added
-
-- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
-- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
- - Collapsing header with cover art and gradient overlay
- - Card-based info section with rounded corners (20px radius)
- - Tonal download buttons with circular shape
- - Quality picker bottom sheet with drag handle
-- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
-
-### Changed
-
-- **Navigation Architecture**: Refactored from state-based to screen-based navigation
- - Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
- - Enables native predictive back gesture animations
- - Search results stay on Home tab for quick downloads
-- **Simplified State Management**: Removed `previousState` chain from TrackProvider since Navigator handles back navigation
-
-## [1.6.2] - 2026-01-02
-
-### Added
-
-- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
-
-### Changed
-
-- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
-- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
-- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
-
-### Fixed
-
-- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
-
-### Performance
-
-- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
-- **List Keys**: Added keys to all list builders for efficient list updates and reordering
-- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
-- **Debounced URL Fetches**: All network requests now debounced to prevent rapid duplicate calls
-- **Bounded File Cache**: File existence cache now limited to 500 entries to prevent memory leak
-- **Timer Cleanup**: Progress polling timer properly disposed when provider is destroyed
-- **Stream Error Handling**: Share intent stream now has proper error handling
-
-## [1.6.1] - 2026-01-02
-
-### Added
-
-- **Background Download Service**: Downloads now continue running when app is in background
- - Foreground service with wake lock prevents Android from killing downloads
- - Persistent notification shows download progress
- - No more "connection abort" errors when switching apps
-
-### Fixed
-
-- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
- - Download queue is now persisted to storage and automatically restored on app restart
- - Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
- - Changed launch mode to `singleTask` to reuse existing activity instead of restarting
- - Added `onNewIntent` handler to properly receive new share intents
-- **Back Button During Loading**: Back button no longer clears state while loading shared URL
-
-### Changed
-
-- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
-
-## [1.6.0] - 2026-01-02
-
-### Added
-
-- **Manual Quality Selection**: New option to choose audio quality before each download
- - Toggle "Ask Before Download" in Download Settings
- - When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
- - Works for both single track and batch downloads
-- **Live Search**: Search results appear as you type with 400ms debounce
- - Animated search bar moves from center to top when typing
- - Keyboard stays open during transition
- - Back button navigates through search history (album → artist → idle)
- - Clear button to reset search
- - URLs still require manual submit
-- **Search Tab Header**: Added collapsing app bar to centered search view for consistent UI across all tabs
-- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
-
-### Fixed
-
-- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
- - Users on hotfix versions now properly receive update notifications
- - Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
-- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
-
-### Changed
-
-- **Settings UI Redesign**: New Android-style grouped settings with connected cards
- - Items in same group are connected with rounded card container
- - Section headers outside cards for clear visual hierarchy
- - Better contrast with white overlay for dark mode dynamic colors
-- **Larger Tab Titles**: Increased app bar title size (28px) and height (130px) for better visibility
-- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
-
-### Improved
-
-- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
-- **Dependencies Updated**:
- - `share_plus`: 10.1.4 → 12.0.1
- - `flutter_local_notifications`: 18.0.1 → 19.0.0
- - `build_runner`: 2.4.15 → 2.10.4
-
-## [1.5.5] - 2026-01-02
-
-### Added
-
-- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
- - Supports track, album, playlist, and artist URLs
- - Auto-fetches metadata when link is shared
- - Works with both `open.spotify.com` URLs and `spotify:` URIs
-- **Lyrics Viewer**: View lyrics for downloaded tracks in Track Metadata screen
- - Fetches lyrics from LRCLIB on-demand
- - Clean display without timestamps
- - Copy lyrics to clipboard
-- **Artist URL Support**: Paste artist URL to browse their discography
- - Shows all albums, singles, and compilations
- - Horizontal scrollable album cards grouped by type
- - Tap any album to view and download its tracks
-- **Folder Organization**: Organize downloads into folders by artist or album
- - Options: None, By Artist, By Album, By Artist & Album
- - Configurable in Settings > Download
-- **Japanese Lyrics to Romaji**: Auto-convert Hiragana/Katakana lyrics to romaji
- - Useful for non-Japanese speakers who want to sing along
- - Toggle in Settings > Options > Lyrics
- - Kanji characters are preserved (requires dictionary lookup)
-- **History View Mode**: Choose between grid or list view for download history
- - Grid view shows album art in a 3-column layout (default)
- - List view shows detailed track info with date
- - Configurable in Settings > Appearance > Layout
-- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
-
-### Changed
-
-- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
- - Shows download queue at top when active
- - Completed downloads auto-move to history section
- - Cleaner separation between active downloads and history
-- **Smarter Back Navigation**: Back button now navigates properly
- - Goes back through search history (album → artist → empty)
- - Returns to Search tab from other tabs
- - Only shows exit dialog when truly at root
-
-### Fixed
-
-- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added)
-- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views
-- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
-
-### Improved
-
-- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search
-- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab
-- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones
-- **Back Navigation**: Android back button now works as expected - returns to previous view (album → artist → empty search)
-
-## [1.5.0-hotfix6] - 2026-01-02
-
-### Fixed
-
-- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
-
-## [1.5.0-hotfix5] - 2026-01-02
-
-### Fixed
-
-- **App Signing**: Use key.properties as per Flutter official documentation
-
-## [1.5.0-hotfix4] - 2026-01-02
-
-### Fixed
-
-- **App Signing**: Create keystore.properties in workflow for Gradle
-
-## [1.5.0-hotfix] - 2026-01-02
-
-### Important Notice
-
-We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
-
-**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
-
-### Added
-
-- **In-App Update**: Download and install updates directly from the app
- - Progress bar shows download status
- - Automatic device architecture detection (arm64/arm32)
- - Downloads correct APK for your device
-- **Consistent App Signing**: All future releases will use the same signing key
-
-### Fixed
-
-- **Update Checker**: Now downloads APK directly instead of opening browser
-
-## [1.5.0] - 2026-01-02
-
-### Added
-
-- **Download Progress Notification**: Shows notification with download progress percentage while downloading
- - Progress bar in notification during download
- - Completion notification when track finishes
- - Summary notification when all downloads complete
-- **Notification Permission in Setup**: Android 13+ users will be prompted for notification permission during initial setup
- - New step in setup wizard for notification permission
- - Option to skip if user doesn't want notifications
-- **Per-Item Queue Controls**: Each track in download queue now has individual controls
- - Cancel button for queued items
- - Stop button for currently downloading items
- - Retry and Remove buttons for failed/skipped items
- - Visual progress bar with percentage for each downloading track
-- **Pull-to-Refresh on Home**: Swipe down to clear URL input and fetched tracks
- - No need to exit app to clear current search/fetch
-- **Multi-Progress Tracking for Concurrent Downloads**: Each concurrent download now shows individual progress percentage
- - Previously concurrent downloads jumped from 0% to 100%
- - Now each track shows real-time progress when downloading in parallel
-- **In-App Update**: Download and install updates directly from the app
- - Progress bar shows download status
- - Automatic device architecture detection (arm64/arm32)
- - Downloads correct APK for your device
-
-### Changed
-
-- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
-- **Queue UI Redesign**: Card-based layout with clearer status indicators
- - Removed global pause/resume in favor of per-item controls
- - Better visual hierarchy with cover art, track info, and action buttons
-- **Settings UI**: Redesigned with category-based navigation (One UI style)
- - Main settings tab with 4 categories: Appearance, Download, Options, About
- - Each category opens a detail page
- - Large title at top with menu items below
- - One-handed friendly layout
-- **Collapsing Toolbar**: Implemented One UI style collapsing header for all tabs
- - Title animates from 28px (expanded) to 20px (collapsed)
- - Back button only on settings detail pages
- - Consistent across Home, Downloads, and Settings tabs
-- **Home Search Bar Redesign**: More prominent and user-friendly input
- - Larger card-style search bar with border outline
- - Tap to open bottom sheet with full input experience
- - Paste and Search buttons clearly visible
- - Helper text showing supported URL types
-- **Empty State Improved**: Better onboarding for new users
- - "Ready to Download" title with icon
- - Clear instructions on how to use the app
- - "Add Music" button for quick access
-
-### Technical
-
-- Added `flutter_local_notifications` package for notifications
-- Added notification permission request in setup screen for Android 13+
-- Enabled core library desugaring for all Android subprojects
-- Added multi-progress tracking in Go backend (`ItemProgress`, `ItemProgressWriter`)
-- Added `GetAllDownloadProgress`, `InitItemProgress`, `FinishItemProgress`, `ClearItemProgress` exports
-- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
-
-### Performance
-
-- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
-- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
-- Optimized state management: Use `select()` to only rebuild when specific state changes
-- Smoother animations: Changed to `BouncingScrollPhysics` and `Curves.easeOutCubic`
-
-## [1.2.0] - 2026-01-02
-
-### Added
-
-- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
- - Material Expressive 3 design with cover art header and gradient
- - Hero animation from list to detail view
- - Displays: track name, artist, album artist, album, track number, disc number, duration, release date, ISRC, Spotify ID, quality, service, download date
- - File info: format (FLAC/M4A), file size, quality badge, service badge with colors
- - Tap to copy ISRC and Spotify ID
- - "Open in Spotify" button to open track in Spotify app/browser
- - File path display with copy functionality
- - Play and Delete action buttons
-- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
-
-### Fixed
-
-- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
- - Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
-- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
-- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
-
-### Changed
-
-- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
- - Play button still available for quick playback
-- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
-- Removed unused `history_screen.dart` and `history_tab.dart` files
-
-## [1.1.2] - 2026-01-01
-
-### Added
-
-- **Update Checker**: Automatic check for new versions from GitHub releases
- - Shows changelog in update dialog
- - Option to disable update notifications
-- **Release Changelog**: GitHub releases now include full changelog
-
-### Changed
-
-- Updated version to 1.1.2
-
-## [1.1.1] - 2026-01-01
-
-### Fixed
-
-- **About Dialog**: Custom About dialog with cleaner layout
-- **Setup Screen**: Fixed step indicator line alignment
-- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
-- **Copyright Year**: Updated to 2026
-
-### Changed
-
-- Removed Theme Preview from Settings
-- Added MIT License
-
-## [1.1.0] - 2026-01-01
-
-### Added
-
-- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
- - Default: Sequential (1 at a time) for stability
- - Options: 1, 2, or 3 concurrent downloads
- - Warning about potential rate limiting from streaming services
-- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
-- **History Persistence**: Download history now persists across app restarts using SharedPreferences
-- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
-- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
-
-### Fixed
-
-- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
-- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
-- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
-- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
-
-### Changed
-
-- Updated version to 1.1.0
-
-### Technical Details
-
-- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
-- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
-- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
-- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
-- Added shared `http.Transport` with connection pooling in `httputil.go`
-- Added `CleanupConnections()` export for Flutter to call via method channel
-
-## [1.0.5] - Previous Release
-
-- Material Expressive 3 UI
-- Dynamic color support
-- Swipe navigation with PageView
-- Settings as bottom navigation tab
-- APK size optimization
+*For older versions, see [GitHub Releases](https://github.com/zarzet/SpotiFLAC-Mobile/releases)*
diff --git a/README.md b/README.md
index 3167ac09..0e2811d1 100644
--- a/README.md
+++ b/README.md
@@ -11,16 +11,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc


-
-
-
-
-
-
-
-
-
-
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
@@ -64,6 +54,18 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
+## Telegram
+
+
+
+
+
+
+
+
+
+
+
## FAQ
**Q: Why is my download failing with "Song not found"?**
@@ -78,12 +80,12 @@ A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tr
**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: Why is the mobile app so large (~50MB) compared to the PC version (~3MB)?**
-A: The mobile app includes FFmpeg libraries for audio processing and format conversion, which adds significant size. The PC version relies on system-installed FFmpeg, keeping the download smaller. We bundle FFmpeg to ensure compatibility across all Android devices without requiring users to install additional software.
-
**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).
+**Q: Why is download not working in my country?**
+A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
+
## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
index 625bfd66..f6c2e457 100644
--- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
@@ -139,6 +139,28 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
+ "checkDuplicatesBatch" -> {
+ val outputDir = call.argument("output_dir") ?: ""
+ val tracksJson = call.argument("tracks") ?: "[]"
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.checkDuplicatesBatch(outputDir, tracksJson)
+ }
+ result.success(response)
+ }
+ "preBuildDuplicateIndex" -> {
+ val outputDir = call.argument("output_dir") ?: ""
+ withContext(Dispatchers.IO) {
+ Gobackend.preBuildDuplicateIndex(outputDir)
+ }
+ result.success(null)
+ }
+ "invalidateDuplicateIndex" -> {
+ val outputDir = call.argument("output_dir") ?: ""
+ withContext(Dispatchers.IO) {
+ Gobackend.invalidateDuplicateIndex(outputDir)
+ }
+ result.success(null)
+ }
"buildFilename" -> {
val template = call.argument("template") ?: ""
val metadata = call.argument("metadata") ?: "{}"
@@ -306,6 +328,43 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
+ "checkAvailabilityFromDeezerID" -> {
+ val deezerTrackId = call.argument("deezer_track_id") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.checkAvailabilityFromDeezerID(deezerTrackId)
+ }
+ result.success(response)
+ }
+ "checkAvailabilityByPlatformID" -> {
+ val platform = call.argument("platform") ?: ""
+ val entityType = call.argument("entity_type") ?: ""
+ val entityId = call.argument("entity_id") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId)
+ }
+ result.success(response)
+ }
+ "getSpotifyIDFromDeezerTrack" -> {
+ val deezerTrackId = call.argument("deezer_track_id") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.getSpotifyIDFromDeezerTrack(deezerTrackId)
+ }
+ result.success(response)
+ }
+ "getTidalURLFromDeezerTrack" -> {
+ val deezerTrackId = call.argument("deezer_track_id") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.getTidalURLFromDeezerTrack(deezerTrackId)
+ }
+ result.success(response)
+ }
+ "getAmazonURLFromDeezerTrack" -> {
+ val deezerTrackId = call.argument("deezer_track_id") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
+ }
+ result.success(response)
+ }
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
@@ -468,6 +527,14 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
+ "enrichTrackWithExtension" -> {
+ val extensionId = call.argument("extension_id") ?: ""
+ val trackJson = call.argument("track") ?: "{}"
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.enrichTrackWithExtensionJSON(extensionId, trackJson)
+ }
+ result.success(response)
+ }
"removeExtension" -> {
val extensionId = call.argument("extension_id") ?: ""
withContext(Dispatchers.IO) {
@@ -678,6 +745,21 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
+ // Extension Home Feed (Explore)
+ "getExtensionHomeFeed" -> {
+ val extensionId = call.argument("extension_id") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.getExtensionHomeFeedJSON(extensionId)
+ }
+ result.success(response)
+ }
+ "getExtensionBrowseCategories" -> {
+ val extensionId = call.argument("extension_id") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.getExtensionBrowseCategoriesJSON(extensionId)
+ }
+ result.success(response)
+ }
else -> result.notImplemented()
}
} catch (e: Exception) {
diff --git a/go_backend/deezer.go b/go_backend/deezer.go
index 2fb92c58..311080cd 100644
--- a/go_backend/deezer.go
+++ b/go_backend/deezer.go
@@ -325,6 +325,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
Name: album.Title,
ReleaseDate: album.ReleaseDate,
Artists: artistName,
+ ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
Images: albumImage,
Genre: genreStr, // From Deezer album
Label: album.Label, // From Deezer album
@@ -339,10 +340,16 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
albumType = "compilation"
}
- for _, track := range album.Tracks.Data {
+ for i, track := range album.Tracks.Data {
trackIDStr := fmt.Sprintf("%d", track.ID)
isrc := isrcMap[trackIDStr]
+ // Use track position from API, fallback to index+1 if not provided
+ trackNum := track.TrackPosition
+ if trackNum == 0 {
+ trackNum = i + 1
+ }
+
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name,
@@ -352,7 +359,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
DurationMS: track.Duration * 1000,
Images: albumImage,
ReleaseDate: album.ReleaseDate,
- TrackNumber: track.TrackPosition,
+ TrackNumber: trackNum,
TotalTracks: album.NbTracks,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
diff --git a/go_backend/exports.go b/go_backend/exports.go
index 17112d93..46236cb4 100644
--- a/go_backend/exports.go
+++ b/go_backend/exports.go
@@ -615,10 +615,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
}
result := map[string]interface{}{
- "success": true,
- "source": lyrics.Source,
- "sync_type": lyrics.SyncType,
- "lines": lyrics.Lines,
+ "success": true,
+ "source": lyrics.Source,
+ "sync_type": lyrics.SyncType,
+ "lines": lyrics.Lines,
+ "instrumental": lyrics.Instrumental,
}
jsonBytes, err := json.Marshal(result)
@@ -630,11 +631,15 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
}
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
+ // If filePath is provided, ONLY check file - don't fallback to online
+ // This allows Flutter to distinguish between "from file" vs "from online"
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
return lyrics, nil
}
+ // File has no lyrics - return empty, let Flutter call again without filePath
+ return "", nil
}
client := NewLyricsClient()
@@ -644,6 +649,11 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return "", err
}
+ // Return special marker for instrumental tracks
+ if lyricsData.Instrumental {
+ return "[instrumental:true]", nil
+ }
+
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
@@ -1698,6 +1708,11 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
if trackCover == "" {
trackCover = album.CoverURL
}
+ // Use track number from extension, fallback to index+1 if not provided
+ trackNum := track.TrackNumber
+ if trackNum == 0 {
+ trackNum = i + 1
+ }
tracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
@@ -1707,7 +1722,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"duration_ms": track.DurationMS,
"cover_url": trackCover,
"release_date": track.ReleaseDate,
- "track_number": track.TrackNumber,
+ "track_number": trackNum,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
@@ -1720,6 +1735,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
+ "artist_id": album.ArtistID,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
@@ -2082,3 +2098,53 @@ func ClearStoreCacheJSON() error {
store.ClearCache()
return nil
}
+
+func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
+ manager := GetExtensionManager()
+ ext, err := manager.GetExtension(extensionID)
+ if err != nil {
+ return "", err
+ }
+
+ if !ext.Enabled {
+ return "", fmt.Errorf("extension '%s' is disabled", extensionID)
+ }
+
+ provider := NewExtensionProviderWrapper(ext)
+
+ script := fmt.Sprintf(`
+ (function() {
+ if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
+ return extension.%s();
+ }
+ return null;
+ })()
+ `, functionName, functionName)
+
+ result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
+ if err != nil {
+ return "", fmt.Errorf("%s failed: %w", functionName, err)
+ }
+
+ if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
+ return "", fmt.Errorf("%s returned null", functionName)
+ }
+
+ exported := result.Export()
+ jsonBytes, err := json.Marshal(exported)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal result: %w", err)
+ }
+
+ return string(jsonBytes), nil
+}
+
+// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
+func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
+ return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
+}
+
+// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
+func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
+ return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
+}
diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go
index baa4ba72..706a32b9 100644
--- a/go_backend/extension_manager.go
+++ b/go_backend/extension_manager.go
@@ -719,27 +719,28 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions()
type ExtensionInfo struct {
- ID string `json:"id"`
- Name string `json:"name"`
- DisplayName string `json:"display_name"`
- Version string `json:"version"`
- Author string `json:"author"`
- Description string `json:"description"`
- Homepage string `json:"homepage,omitempty"`
- IconPath string `json:"icon_path,omitempty"`
- Types []ExtensionType `json:"types"`
- Enabled bool `json:"enabled"`
- Status string `json:"status"`
- Error string `json:"error_message,omitempty"`
- Settings []ExtensionSetting `json:"settings,omitempty"`
- QualityOptions []QualityOption `json:"quality_options,omitempty"`
- Permissions []string `json:"permissions"`
- HasMetadataProvider bool `json:"has_metadata_provider"`
- HasDownloadProvider bool `json:"has_download_provider"`
- SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
- SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
- TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
- PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ DisplayName string `json:"display_name"`
+ Version string `json:"version"`
+ Author string `json:"author"`
+ Description string `json:"description"`
+ Homepage string `json:"homepage,omitempty"`
+ IconPath string `json:"icon_path,omitempty"`
+ Types []ExtensionType `json:"types"`
+ Enabled bool `json:"enabled"`
+ Status string `json:"status"`
+ Error string `json:"error_message,omitempty"`
+ Settings []ExtensionSetting `json:"settings,omitempty"`
+ QualityOptions []QualityOption `json:"quality_options,omitempty"`
+ Permissions []string `json:"permissions"`
+ HasMetadataProvider bool `json:"has_metadata_provider"`
+ HasDownloadProvider bool `json:"has_download_provider"`
+ SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
+ SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
+ TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
+ PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
+ Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
infos := make([]ExtensionInfo, len(extensions))
@@ -796,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing,
+ Capabilities: ext.Manifest.Capabilities,
}
}
diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go
index 7a850a55..65740067 100644
--- a/go_backend/extension_manifest.go
+++ b/go_backend/extension_manifest.go
@@ -107,24 +107,25 @@ type PostProcessingConfig struct {
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct {
- Name string `json:"name"`
- DisplayName string `json:"displayName"`
- Version string `json:"version"`
- Author string `json:"author"`
- Description string `json:"description"`
- Homepage string `json:"homepage,omitempty"`
- Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
- Types []ExtensionType `json:"type"`
- Permissions ExtensionPermissions `json:"permissions"`
- Settings []ExtensionSetting `json:"settings,omitempty"`
- QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
- MinAppVersion string `json:"minAppVersion,omitempty"`
- SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
- SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
- SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
- URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
- TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
- PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
+ Name string `json:"name"`
+ DisplayName string `json:"displayName"`
+ Version string `json:"version"`
+ Author string `json:"author"`
+ Description string `json:"description"`
+ Homepage string `json:"homepage,omitempty"`
+ Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
+ Types []ExtensionType `json:"type"`
+ Permissions ExtensionPermissions `json:"permissions"`
+ Settings []ExtensionSetting `json:"settings,omitempty"`
+ QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
+ MinAppVersion string `json:"minAppVersion,omitempty"`
+ SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
+ SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
+ SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
+ URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
+ TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
+ PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
+ Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
}
// ManifestValidationError represents a validation error in the manifest
diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go
index 672a06c9..4f66aeca 100644
--- a/go_backend/extension_providers.go
+++ b/go_backend/extension_providers.go
@@ -58,6 +58,7 @@ type ExtAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
+ ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go
index 37d86920..568b2366 100644
--- a/go_backend/extension_runtime_utils.go
+++ b/go_backend/extension_runtime_utils.go
@@ -12,6 +12,7 @@ import (
"encoding/json"
"fmt"
"strings"
+ "time"
"github.com/dop251/goja"
)
@@ -371,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
})
+
+ // Expose getLocalTime - returns device local time info
+ obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
+ now := time.Now()
+ _, offsetSeconds := now.Zone()
+ offsetMinutes := offsetSeconds / 60
+
+ return vm.ToValue(map[string]interface{}{
+ "year": now.Year(),
+ "month": int(now.Month()),
+ "day": now.Day(),
+ "hour": now.Hour(),
+ "minute": now.Minute(),
+ "second": now.Second(),
+ "weekday": int(now.Weekday()),
+ "offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
+ "timezone": now.Location().String(),
+ "timestamp": now.Unix(),
+ })
+ })
}
diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go
index b22b200a..c82ed04a 100644
--- a/go_backend/lyrics.go
+++ b/go_backend/lyrics.go
@@ -240,7 +240,10 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
// 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
+ // Normalize artist name - take first artist before comma/semicolon for better matching
+ primaryArtist := normalizeArtistName(artistName)
+
+ // Check cache first (use original artist name for cache key)
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
@@ -251,29 +254,44 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
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 {
+ // Helper to check if lyrics result is valid (has lines OR is instrumental)
+ isValidResult := func(l *LyricsResponse) bool {
+ return l != nil && (len(l.Lines) > 0 || l.Instrumental)
+ }
+
+ // Try exact match first with primary artist
+ lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
+ if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
+ // Try with full artist name if different from primary
+ if primaryArtist != artistName {
+ lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
+ if err == nil && isValidResult(lyrics) {
+ lyrics.Source = "LRCLIB"
+ globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
+ return lyrics, nil
+ }
+ }
+
// 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, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
+ if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
- // Search with duration matching
- query := artistName + " " + trackName
+ // Search with duration matching (use primary artist for search)
+ query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
- if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
+ if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
@@ -281,9 +299,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
// Search with simplified name and duration matching
if simplifiedTrack != trackName {
- query = artistName + " " + simplifiedTrack
+ query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
- if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
+ if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
@@ -462,6 +480,24 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result)
}
+// normalizeArtistName extracts the primary artist from multi-artist strings
+// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
+// e.g., "Artist1; Artist2" -> "Artist1"
+func normalizeArtistName(name string) string {
+ // Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
+ separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
+
+ result := name
+ for _, sep := range separators {
+ if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
+ result = result[:idx]
+ break
+ }
+ }
+
+ return strings.TrimSpace(result)
+}
+
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" {
return "", fmt.Errorf("empty LRC content")
diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go
index 40c24d86..06ce66d6 100644
--- a/go_backend/qobuz.go
+++ b/go_backend/qobuz.go
@@ -1072,13 +1072,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
albumName = req.AlbumName
}
+ // Use track number from request if available, otherwise from Qobuz API
+ actualTrackNumber := req.TrackNumber
+ if actualTrackNumber == 0 {
+ actualTrackNumber = track.TrackNumber
+ }
+
metadata := Metadata{
Title: track.Title,
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
Date: track.Album.ReleaseDate,
- TrackNumber: track.TrackNumber,
+ TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
ISRC: track.ISRC,
@@ -1135,7 +1141,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
- TrackNumber: track.TrackNumber,
+ TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
ISRC: track.ISRC,
}, nil
diff --git a/go_backend/spotify.go b/go_backend/spotify.go
index 65b37143..e212c625 100644
--- a/go_backend/spotify.go
+++ b/go_backend/spotify.go
@@ -170,6 +170,7 @@ type AlbumInfoMetadata struct {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
Artists string `json:"artists"`
+ ArtistId string `json:"artist_id,omitempty"`
Images string `json:"images"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
@@ -512,11 +513,19 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
albumImage := firstImageURL(data.Images)
+
+ // Get first artist ID
+ var firstArtistId string
+ if len(data.Artists) > 0 {
+ firstArtistId = data.Artists[0].ID
+ }
+
info := AlbumInfoMetadata{
TotalTracks: data.TotalTracks,
Name: data.Name,
ReleaseDate: data.ReleaseDate,
Artists: joinArtists(data.Artists),
+ ArtistId: firstArtistId,
Images: albumImage,
}
diff --git a/go_backend/tidal.go b/go_backend/tidal.go
index f489741a..9b64aa91 100644
--- a/go_backend/tidal.go
+++ b/go_backend/tidal.go
@@ -331,7 +331,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
-
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
token, err := t.GetAccessToken()
@@ -630,7 +629,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
- if v2Response.Data.AssetPresentation == "PREVIEW" {
+ if v2Response.Data.AssetPresentation == "PREVIEW" {
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
return
}
@@ -903,7 +902,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
- if isDownloadCancelled(itemID) {
+ if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
@@ -1346,7 +1345,6 @@ func isLatinScript(s string) bool {
return true
}
-
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader()
@@ -1593,15 +1591,25 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
}
+ // Use track number from request if available, otherwise from Tidal API
+ actualTrackNumber := req.TrackNumber
+ actualDiscNumber := req.DiscNumber
+ if actualTrackNumber == 0 {
+ actualTrackNumber = track.TrackNumber
+ }
+ if actualDiscNumber == 0 {
+ actualDiscNumber = track.VolumeNumber
+ }
+
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: releaseDate,
- TrackNumber: track.TrackNumber,
+ TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
- DiscNumber: track.VolumeNumber,
+ DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
@@ -1659,8 +1667,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
Artist: track.Artist.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
- TrackNumber: track.TrackNumber,
- DiscNumber: track.VolumeNumber,
+ TrackNumber: actualTrackNumber,
+ DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
}, nil
}
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index c6a373d7..94809a20 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -142,6 +142,27 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
+ case "checkDuplicatesBatch":
+ let args = call.arguments as! [String: Any]
+ let outputDir = args["output_dir"] as! String
+ let tracksJson = args["tracks"] as? String ?? "[]"
+ let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
+ if let error = error { throw error }
+ return response
+
+ case "preBuildDuplicateIndex":
+ let args = call.arguments as! [String: Any]
+ let outputDir = args["output_dir"] as! String
+ GobackendPreBuildDuplicateIndex(outputDir, &error)
+ if let error = error { throw error }
+ return nil
+
+ case "invalidateDuplicateIndex":
+ let args = call.arguments as! [String: Any]
+ let outputDir = args["output_dir"] as! String
+ GobackendInvalidateDuplicateIndex(outputDir)
+ return nil
+
case "buildFilename":
let args = call.arguments as! [String: Any]
let template = args["template"] as! String
@@ -249,6 +270,43 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
+ case "checkAvailabilityFromDeezerID":
+ let args = call.arguments as! [String: Any]
+ let deezerTrackId = args["deezer_track_id"] as! String
+ let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
+ if let error = error { throw error }
+ return response
+
+ case "checkAvailabilityByPlatformID":
+ let args = call.arguments as! [String: Any]
+ let platform = args["platform"] as! String
+ let entityType = args["entity_type"] as! String
+ let entityId = args["entity_id"] as! String
+ let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
+ if let error = error { throw error }
+ return response
+
+ case "getSpotifyIDFromDeezerTrack":
+ let args = call.arguments as! [String: Any]
+ let deezerTrackId = args["deezer_track_id"] as! String
+ let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
+ if let error = error { throw error }
+ return response
+
+ case "getTidalURLFromDeezerTrack":
+ let args = call.arguments as! [String: Any]
+ let deezerTrackId = args["deezer_track_id"] as! String
+ let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
+ if let error = error { throw error }
+ return response
+
+ case "getAmazonURLFromDeezerTrack":
+ let args = call.arguments as! [String: Any]
+ let deezerTrackId = args["deezer_track_id"] as! String
+ let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
+ if let error = error { throw error }
+ return response
+
case "preWarmTrackCache":
let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String
@@ -404,6 +462,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
+ case "enrichTrackWithExtension":
+ let args = call.arguments as! [String: Any]
+ let extensionId = args["extension_id"] as! String
+ let trackJson = args["track"] as? String ?? "{}"
+ let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
+ if let error = error { throw error }
+ return response
+
case "removeExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -605,6 +671,21 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
+ // Extension Home Feed API
+ case "getExtensionHomeFeed":
+ let args = call.arguments as! [String: Any]
+ let extensionId = args["extension_id"] as! String
+ let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
+ if let error = error { throw error }
+ return response
+
+ case "getExtensionBrowseCategories":
+ let args = call.arguments as! [String: Any]
+ let extensionId = args["extension_id"] as! String
+ let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error)
+ if let error = error { throw error }
+ return response
+
default:
throw NSError(
domain: "SpotiFLAC",
diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart
index 66e8a243..841ab631 100644
--- a/lib/constants/app_info.dart
+++ b/lib/constants/app_info.dart
@@ -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.3';
- static const String buildNumber = '62';
+static const String version = '3.2.1';
+ static const String buildNumber = '64';
static const String fullVersion = '$version+$buildNumber';
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 4cdaa83d..be159c0e 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -16,6 +16,7 @@ import 'app_localizations_ko.dart';
import 'app_localizations_nl.dart';
import 'app_localizations_pt.dart';
import 'app_localizations_ru.dart';
+import 'app_localizations_tr.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -117,6 +118,7 @@ abstract class AppLocalizations {
Locale('pt'),
Locale('pt', 'PT'),
Locale('ru'),
+ Locale('tr'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
@@ -278,6 +280,12 @@ abstract class AppLocalizations {
/// **'Single track downloads will appear here'**
String get historyNoSinglesSubtitle;
+ /// Search bar placeholder in history
+ ///
+ /// In en, this message translates to:
+ /// **'Search history...'**
+ String get historySearchHint;
+
/// Settings screen title
///
/// In en, this message translates to:
@@ -872,6 +880,36 @@ abstract class AppLocalizations {
/// **'Suggest new features for the app'**
String get aboutFeatureRequestSubtitle;
+ /// Link to Telegram channel
+ ///
+ /// In en, this message translates to:
+ /// **'Telegram Channel'**
+ String get aboutTelegramChannel;
+
+ /// Subtitle for Telegram channel
+ ///
+ /// In en, this message translates to:
+ /// **'Announcements and updates'**
+ String get aboutTelegramChannelSubtitle;
+
+ /// Link to Telegram chat group
+ ///
+ /// In en, this message translates to:
+ /// **'Telegram Community'**
+ String get aboutTelegramChat;
+
+ /// Subtitle for Telegram chat
+ ///
+ /// In en, this message translates to:
+ /// **'Chat with other users'**
+ String get aboutTelegramChatSubtitle;
+
+ /// Section for social links
+ ///
+ /// In en, this message translates to:
+ /// **'Social'**
+ String get aboutSocial;
+
/// Section for support/donation links
///
/// In en, this message translates to:
@@ -2924,6 +2962,24 @@ abstract class AppLocalizations {
/// **'Failed to load lyrics'**
String get trackLyricsLoadFailed;
+ /// Action - embed lyrics into audio file
+ ///
+ /// In en, this message translates to:
+ /// **'Embed Lyrics'**
+ String get trackEmbedLyrics;
+
+ /// Snackbar - lyrics saved to file
+ ///
+ /// In en, this message translates to:
+ /// **'Lyrics embedded successfully'**
+ String get trackLyricsEmbedded;
+
+ /// Message when track is instrumental (no lyrics)
+ ///
+ /// In en, this message translates to:
+ /// **'Instrumental track'**
+ String get trackInstrumental;
+
/// Snackbar - content copied
///
/// In en, this message translates to:
@@ -3650,6 +3706,18 @@ abstract class AppLocalizations {
/// **'Albums/[2005] Album Name/'**
String get albumFolderYearAlbumSubtitle;
+ /// Album folder option with singles inside artist
+ ///
+ /// In en, this message translates to:
+ /// **'Artist / Album + Singles'**
+ String get albumFolderArtistAlbumSingles;
+
+ /// Folder structure example
+ ///
+ /// In en, this message translates to:
+ /// **'Artist/Album/ and Artist/Singles/'**
+ String get albumFolderArtistAlbumSinglesSubtitle;
+
/// Button - delete selected tracks
///
/// In en, this message translates to:
@@ -3751,6 +3819,108 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Error: {message}'**
String errorGeneric(String message);
+
+ /// Button - download artist discography
+ ///
+ /// In en, this message translates to:
+ /// **'Download Discography'**
+ String get discographyDownload;
+
+ /// Option - download entire discography
+ ///
+ /// In en, this message translates to:
+ /// **'Download All'**
+ String get discographyDownloadAll;
+
+ /// Subtitle showing total tracks and albums
+ ///
+ /// In en, this message translates to:
+ /// **'{count} tracks from {albumCount} releases'**
+ String discographyDownloadAllSubtitle(int count, int albumCount);
+
+ /// Option - download only albums
+ ///
+ /// In en, this message translates to:
+ /// **'Albums Only'**
+ String get discographyAlbumsOnly;
+
+ /// Subtitle showing album tracks count
+ ///
+ /// In en, this message translates to:
+ /// **'{count} tracks from {albumCount} albums'**
+ String discographyAlbumsOnlySubtitle(int count, int albumCount);
+
+ /// Option - download only singles
+ ///
+ /// In en, this message translates to:
+ /// **'Singles & EPs Only'**
+ String get discographySinglesOnly;
+
+ /// Subtitle showing singles tracks count
+ ///
+ /// In en, this message translates to:
+ /// **'{count} tracks from {albumCount} singles'**
+ String discographySinglesOnlySubtitle(int count, int albumCount);
+
+ /// Option - manually select albums to download
+ ///
+ /// In en, this message translates to:
+ /// **'Select Albums...'**
+ String get discographySelectAlbums;
+
+ /// Subtitle for select albums option
+ ///
+ /// In en, this message translates to:
+ /// **'Choose specific albums or singles'**
+ String get discographySelectAlbumsSubtitle;
+
+ /// Progress - fetching album tracks
+ ///
+ /// In en, this message translates to:
+ /// **'Fetching tracks...'**
+ String get discographyFetchingTracks;
+
+ /// Progress - fetching specific album
+ ///
+ /// In en, this message translates to:
+ /// **'Fetching {current} of {total}...'**
+ String discographyFetchingAlbum(int current, int total);
+
+ /// Selection count badge
+ ///
+ /// In en, this message translates to:
+ /// **'{count} selected'**
+ String discographySelectedCount(int count);
+
+ /// Button - download selected albums
+ ///
+ /// In en, this message translates to:
+ /// **'Download Selected'**
+ String get discographyDownloadSelected;
+
+ /// Snackbar - tracks added from discography
+ ///
+ /// In en, this message translates to:
+ /// **'Added {count} tracks to queue'**
+ String discographyAddedToQueue(int count);
+
+ /// Snackbar - with skipped tracks count
+ ///
+ /// In en, this message translates to:
+ /// **'{added} added, {skipped} already downloaded'**
+ String discographySkippedDownloaded(int added, int skipped);
+
+ /// Error - no albums found for artist
+ ///
+ /// In en, this message translates to:
+ /// **'No albums available'**
+ String get discographyNoAlbums;
+
+ /// Error - some albums failed to load
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to fetch some albums'**
+ String get discographyFailedToFetch;
}
class _AppLocalizationsDelegate
@@ -3775,6 +3945,7 @@ class _AppLocalizationsDelegate
'nl',
'pt',
'ru',
+ 'tr',
'zh',
].contains(locale.languageCode);
@@ -3837,6 +4008,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsPt();
case 'ru':
return AppLocalizationsRu();
+ case 'tr':
+ return AppLocalizationsTr();
case 'zh':
return AppLocalizationsZh();
}
diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart
index 2dd62b0d..0cbbbefa 100644
--- a/lib/l10n/app_localizations_de.dart
+++ b/lib/l10n/app_localizations_de.dart
@@ -111,6 +111,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Einzelne Titel-Downloads werden hier angezeigt';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Einstellungen';
@@ -441,6 +444,21 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutFeatureRequestSubtitle =>
'Schlage neue Funktionen für die App vor';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1613,6 +1631,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -2001,6 +2028,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2077,4 +2111,70 @@ class AppLocalizationsDe extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index de382bda..9eff2a37 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -109,6 +109,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsEn extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart
index 0ff7baad..0a47926d 100644
--- a/lib/l10n/app_localizations_es.dart
+++ b/lib/l10n/app_localizations_es.dart
@@ -109,6 +109,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,6 +2098,72 @@ class AppLocalizationsEs extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart
index 92bf6148..74b8e6c0 100644
--- a/lib/l10n/app_localizations_fr.dart
+++ b/lib/l10n/app_localizations_fr.dart
@@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsFr extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart
index f95470da..9a690ec5 100644
--- a/lib/l10n/app_localizations_hi.dart
+++ b/lib/l10n/app_localizations_hi.dart
@@ -109,6 +109,9 @@ class AppLocalizationsHi extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsHi extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart
index 258ebad7..3fa36e0e 100644
--- a/lib/l10n/app_localizations_id.dart
+++ b/lib/l10n/app_localizations_id.dart
@@ -110,6 +110,9 @@ class AppLocalizationsId extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Unduhan lagu satuan akan muncul di sini';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Pengaturan';
@@ -434,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutFeatureRequestSubtitle =>
'Sarankan fitur baru untuk aplikasi';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Dukungan';
@@ -1610,6 +1628,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Disalin ke clipboard';
@@ -2001,6 +2028,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -2077,4 +2111,70 @@ class AppLocalizationsId extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Unduh Diskografi';
+
+ @override
+ String get discographyDownloadAll => 'Unduh Semua';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count lagu dari $albumCount rilis';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Album Saja';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count lagu dari $albumCount album';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Single & EP Saja';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count lagu dari $albumCount single';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Pilih Album...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Pilih album atau single tertentu';
+
+ @override
+ String get discographyFetchingTracks => 'Mengambil lagu...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Mengambil $current dari $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count dipilih';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Unduh yang Dipilih';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Menambahkan $count lagu ke antrian';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added ditambahkan, $skipped sudah diunduh';
+ }
+
+ @override
+ String get discographyNoAlbums => 'Tidak ada album tersedia';
+
+ @override
+ String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
}
diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart
index 9dd91796..76d048b8 100644
--- a/lib/l10n/app_localizations_ja.dart
+++ b/lib/l10n/app_localizations_ja.dart
@@ -109,6 +109,9 @@ class AppLocalizationsJa extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => '設定';
@@ -429,6 +432,21 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsJa extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart
index 4b3487af..fed8e128 100644
--- a/lib/l10n/app_localizations_ko.dart
+++ b/lib/l10n/app_localizations_ko.dart
@@ -109,6 +109,9 @@ class AppLocalizationsKo extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsKo extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart
index 67086594..eecb34ae 100644
--- a/lib/l10n/app_localizations_nl.dart
+++ b/lib/l10n/app_localizations_nl.dart
@@ -109,6 +109,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,4 +2098,70 @@ class AppLocalizationsNl extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index 42c9e1c6..4a1d3424 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -109,6 +109,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,6 +2098,72 @@ class AppLocalizationsPt extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart
index 8bd4c674..15ad6280 100644
--- a/lib/l10n/app_localizations_ru.dart
+++ b/lib/l10n/app_localizations_ru.dart
@@ -114,6 +114,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Здесь будут отображаться загрузки синглов';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Настройки';
@@ -442,6 +445,21 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutFeatureRequestSubtitle =>
'Предложить новые функции для приложения';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Поддержка';
@@ -1634,6 +1652,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Скопировано в буфер обмена';
@@ -2029,6 +2056,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderYearAlbumSubtitle =>
'Альбомы/[2005] Название Альбома /';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
@@ -2109,4 +2143,70 @@ class AppLocalizationsRu extends AppLocalizations {
String errorGeneric(String message) {
return 'Ошибка: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart
new file mode 100644
index 00000000..a1b0f73a
--- /dev/null
+++ b/lib/l10n/app_localizations_tr.dart
@@ -0,0 +1,2167 @@
+// ignore: unused_import
+import 'package:intl/intl.dart' as intl;
+import 'app_localizations.dart';
+
+// ignore_for_file: type=lint
+
+/// The translations for Turkish (`tr`).
+class AppLocalizationsTr extends AppLocalizations {
+ AppLocalizationsTr([String locale = 'tr']) : super(locale);
+
+ @override
+ String get appName => 'SpotiFLAC';
+
+ @override
+ String get appDescription =>
+ 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
+
+ @override
+ String get navHome => 'Home';
+
+ @override
+ String get navHistory => 'History';
+
+ @override
+ String get navSettings => 'Settings';
+
+ @override
+ String get navStore => 'Store';
+
+ @override
+ String get homeTitle => 'Home';
+
+ @override
+ String get homeSearchHint => 'Paste Spotify URL or search...';
+
+ @override
+ String homeSearchHintExtension(String extensionName) {
+ return 'Search with $extensionName...';
+ }
+
+ @override
+ String get homeSubtitle => 'Paste a Spotify link or search by name';
+
+ @override
+ String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
+
+ @override
+ String get homeRecent => 'Recent';
+
+ @override
+ String get historyTitle => 'History';
+
+ @override
+ String historyDownloading(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get historyDownloaded => 'Downloaded';
+
+ @override
+ String get historyFilterAll => 'All';
+
+ @override
+ String get historyFilterAlbums => 'Albums';
+
+ @override
+ String get historyFilterSingles => 'Singles';
+
+ @override
+ String historyTracksCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String historyAlbumsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get historyNoDownloads => 'No download history';
+
+ @override
+ String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get historyNoAlbums => 'No album downloads';
+
+ @override
+ String get historyNoAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get historyNoSingles => 'No single downloads';
+
+ @override
+ String get historyNoSinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get historySearchHint => 'Search history...';
+
+ @override
+ String get settingsTitle => 'Settings';
+
+ @override
+ String get settingsDownload => 'Download';
+
+ @override
+ String get settingsAppearance => 'Appearance';
+
+ @override
+ String get settingsOptions => 'Options';
+
+ @override
+ String get settingsExtensions => 'Extensions';
+
+ @override
+ String get settingsAbout => 'About';
+
+ @override
+ String get downloadTitle => 'Download';
+
+ @override
+ String get downloadLocation => 'Download Location';
+
+ @override
+ String get downloadLocationSubtitle => 'Choose where to save files';
+
+ @override
+ String get downloadLocationDefault => 'Default location';
+
+ @override
+ String get downloadDefaultService => 'Default Service';
+
+ @override
+ String get downloadDefaultServiceSubtitle => 'Service used for downloads';
+
+ @override
+ String get downloadDefaultQuality => 'Default Quality';
+
+ @override
+ String get downloadAskQuality => 'Ask Quality Before Download';
+
+ @override
+ String get downloadAskQualitySubtitle =>
+ 'Show quality picker for each download';
+
+ @override
+ String get downloadFilenameFormat => 'Filename Format';
+
+ @override
+ String get downloadFolderOrganization => 'Folder Organization';
+
+ @override
+ String get downloadSeparateSingles => 'Separate Singles';
+
+ @override
+ String get downloadSeparateSinglesSubtitle =>
+ 'Put single tracks in a separate folder';
+
+ @override
+ String get qualityBest => 'Best Available';
+
+ @override
+ String get qualityFlac => 'FLAC';
+
+ @override
+ String get quality320 => '320 kbps';
+
+ @override
+ String get quality128 => '128 kbps';
+
+ @override
+ String get appearanceTitle => 'Appearance';
+
+ @override
+ String get appearanceTheme => 'Theme';
+
+ @override
+ String get appearanceThemeSystem => 'System';
+
+ @override
+ String get appearanceThemeLight => 'Light';
+
+ @override
+ String get appearanceThemeDark => 'Dark';
+
+ @override
+ String get appearanceDynamicColor => 'Dynamic Color';
+
+ @override
+ String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
+
+ @override
+ String get appearanceAccentColor => 'Accent Color';
+
+ @override
+ String get appearanceHistoryView => 'History View';
+
+ @override
+ String get appearanceHistoryViewList => 'List';
+
+ @override
+ String get appearanceHistoryViewGrid => 'Grid';
+
+ @override
+ String get optionsTitle => 'Options';
+
+ @override
+ String get optionsSearchSource => 'Search Source';
+
+ @override
+ String get optionsPrimaryProvider => 'Primary Provider';
+
+ @override
+ String get optionsPrimaryProviderSubtitle =>
+ 'Service used when searching by track name.';
+
+ @override
+ String optionsUsingExtension(String extensionName) {
+ return 'Using extension: $extensionName';
+ }
+
+ @override
+ String get optionsSwitchBack =>
+ 'Tap Deezer or Spotify to switch back from extension';
+
+ @override
+ String get optionsAutoFallback => 'Auto Fallback';
+
+ @override
+ String get optionsAutoFallbackSubtitle =>
+ 'Try other services if download fails';
+
+ @override
+ String get optionsUseExtensionProviders => 'Use Extension Providers';
+
+ @override
+ String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
+
+ @override
+ String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
+
+ @override
+ String get optionsEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get optionsEmbedLyricsSubtitle =>
+ 'Embed synced lyrics into FLAC files';
+
+ @override
+ String get optionsMaxQualityCover => 'Max Quality Cover';
+
+ @override
+ String get optionsMaxQualityCoverSubtitle =>
+ 'Download highest resolution cover art';
+
+ @override
+ String get optionsConcurrentDownloads => 'Concurrent Downloads';
+
+ @override
+ String get optionsConcurrentSequential => 'Sequential (1 at a time)';
+
+ @override
+ String optionsConcurrentParallel(int count) {
+ return '$count parallel downloads';
+ }
+
+ @override
+ String get optionsConcurrentWarning =>
+ 'Parallel downloads may trigger rate limiting';
+
+ @override
+ String get optionsExtensionStore => 'Extension Store';
+
+ @override
+ String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
+
+ @override
+ String get optionsCheckUpdates => 'Check for Updates';
+
+ @override
+ String get optionsCheckUpdatesSubtitle =>
+ 'Notify when new version is available';
+
+ @override
+ String get optionsUpdateChannel => 'Update Channel';
+
+ @override
+ String get optionsUpdateChannelStable => 'Stable releases only';
+
+ @override
+ String get optionsUpdateChannelPreview => 'Get preview releases';
+
+ @override
+ String get optionsUpdateChannelWarning =>
+ 'Preview may contain bugs or incomplete features';
+
+ @override
+ String get optionsClearHistory => 'Clear Download History';
+
+ @override
+ String get optionsClearHistorySubtitle =>
+ 'Remove all downloaded tracks from history';
+
+ @override
+ String get optionsDetailedLogging => 'Detailed Logging';
+
+ @override
+ String get optionsDetailedLoggingOn => 'Detailed logs are being recorded';
+
+ @override
+ String get optionsDetailedLoggingOff => 'Enable for bug reports';
+
+ @override
+ String get optionsSpotifyCredentials => 'Spotify Credentials';
+
+ @override
+ String optionsSpotifyCredentialsConfigured(String clientId) {
+ return 'Client ID: $clientId...';
+ }
+
+ @override
+ String get optionsSpotifyCredentialsRequired => 'Required - tap to configure';
+
+ @override
+ String get optionsSpotifyWarning =>
+ 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
+
+ @override
+ String get extensionsTitle => 'Extensions';
+
+ @override
+ String get extensionsInstalled => 'Installed Extensions';
+
+ @override
+ String get extensionsNone => 'No extensions installed';
+
+ @override
+ String get extensionsNoneSubtitle => 'Install extensions from the Store tab';
+
+ @override
+ String get extensionsEnabled => 'Enabled';
+
+ @override
+ String get extensionsDisabled => 'Disabled';
+
+ @override
+ String extensionsVersion(String version) {
+ return 'Version $version';
+ }
+
+ @override
+ String extensionsAuthor(String author) {
+ return 'by $author';
+ }
+
+ @override
+ String get extensionsUninstall => 'Uninstall';
+
+ @override
+ String get extensionsSetAsSearch => 'Set as Search Provider';
+
+ @override
+ String get storeTitle => 'Extension Store';
+
+ @override
+ String get storeSearch => 'Search extensions...';
+
+ @override
+ String get storeInstall => 'Install';
+
+ @override
+ String get storeInstalled => 'Installed';
+
+ @override
+ String get storeUpdate => 'Update';
+
+ @override
+ String get aboutTitle => 'About';
+
+ @override
+ String get aboutContributors => 'Contributors';
+
+ @override
+ String get aboutMobileDeveloper => 'Mobile version developer';
+
+ @override
+ String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
+
+ @override
+ String get aboutLogoArtist =>
+ 'The talented artist who created our beautiful app logo!';
+
+ @override
+ String get aboutTranslators => 'Translators';
+
+ @override
+ String get aboutSpecialThanks => 'Special Thanks';
+
+ @override
+ String get aboutLinks => 'Links';
+
+ @override
+ String get aboutMobileSource => 'Mobile source code';
+
+ @override
+ String get aboutPCSource => 'PC source code';
+
+ @override
+ String get aboutReportIssue => 'Report an issue';
+
+ @override
+ String get aboutReportIssueSubtitle => 'Report any problems you encounter';
+
+ @override
+ String get aboutFeatureRequest => 'Feature request';
+
+ @override
+ String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
+ @override
+ String get aboutSupport => 'Support';
+
+ @override
+ String get aboutBuyMeCoffee => 'Buy me a coffee';
+
+ @override
+ String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
+
+ @override
+ String get aboutApp => 'App';
+
+ @override
+ String get aboutVersion => 'Version';
+
+ @override
+ String get aboutBinimumDesc =>
+ 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
+
+ @override
+ String get aboutSachinsenalDesc =>
+ 'The original HiFi project creator. The foundation of Tidal integration!';
+
+ @override
+ String get aboutDoubleDouble => 'DoubleDouble';
+
+ @override
+ String get aboutDoubleDoubleDesc =>
+ 'Amazing API for Amazon Music downloads. Thank you for making it free!';
+
+ @override
+ String get aboutDabMusic => 'DAB Music';
+
+ @override
+ String get aboutDabMusicDesc =>
+ 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
+
+ @override
+ String get aboutAppDescription =>
+ 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
+
+ @override
+ String get albumTitle => 'Album';
+
+ @override
+ String albumTracks(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get albumDownloadAll => 'Download All';
+
+ @override
+ String get albumDownloadRemaining => 'Download Remaining';
+
+ @override
+ String get playlistTitle => 'Playlist';
+
+ @override
+ String get artistTitle => 'Artist';
+
+ @override
+ String get artistAlbums => 'Albums';
+
+ @override
+ String get artistSingles => 'Singles & EPs';
+
+ @override
+ String get artistCompilations => 'Compilations';
+
+ @override
+ String artistReleases(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count releases',
+ one: '1 release',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get artistPopular => 'Popular';
+
+ @override
+ String artistMonthlyListeners(String count) {
+ return '$count monthly listeners';
+ }
+
+ @override
+ String get trackMetadataTitle => 'Track Info';
+
+ @override
+ String get trackMetadataArtist => 'Artist';
+
+ @override
+ String get trackMetadataAlbum => 'Album';
+
+ @override
+ String get trackMetadataDuration => 'Duration';
+
+ @override
+ String get trackMetadataQuality => 'Quality';
+
+ @override
+ String get trackMetadataPath => 'File Path';
+
+ @override
+ String get trackMetadataDownloadedAt => 'Downloaded';
+
+ @override
+ String get trackMetadataService => 'Service';
+
+ @override
+ String get trackMetadataPlay => 'Play';
+
+ @override
+ String get trackMetadataShare => 'Share';
+
+ @override
+ String get trackMetadataDelete => 'Delete';
+
+ @override
+ String get trackMetadataRedownload => 'Re-download';
+
+ @override
+ String get trackMetadataOpenFolder => 'Open Folder';
+
+ @override
+ String get setupTitle => 'Welcome to SpotiFLAC';
+
+ @override
+ String get setupSubtitle => 'Let\'s get you started';
+
+ @override
+ String get setupStoragePermission => 'Storage Permission';
+
+ @override
+ String get setupStoragePermissionSubtitle =>
+ 'Required to save downloaded files';
+
+ @override
+ String get setupStoragePermissionGranted => 'Permission granted';
+
+ @override
+ String get setupStoragePermissionDenied => 'Permission denied';
+
+ @override
+ String get setupGrantPermission => 'Grant Permission';
+
+ @override
+ String get setupDownloadLocation => 'Download Location';
+
+ @override
+ String get setupChooseFolder => 'Choose Folder';
+
+ @override
+ String get setupContinue => 'Continue';
+
+ @override
+ String get setupSkip => 'Skip for now';
+
+ @override
+ String get setupStorageAccessRequired => 'Storage Access Required';
+
+ @override
+ String get setupStorageAccessMessage =>
+ 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.';
+
+ @override
+ String get setupStorageAccessMessageAndroid11 =>
+ 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.';
+
+ @override
+ String get setupOpenSettings => 'Open Settings';
+
+ @override
+ String get setupPermissionDeniedMessage =>
+ 'Permission denied. Please grant all permissions to continue.';
+
+ @override
+ String setupPermissionRequired(String permissionType) {
+ return '$permissionType Permission Required';
+ }
+
+ @override
+ String setupPermissionRequiredMessage(String permissionType) {
+ return '$permissionType permission is required for the best experience. You can change this later in Settings.';
+ }
+
+ @override
+ String get setupSelectDownloadFolder => 'Select Download Folder';
+
+ @override
+ String get setupUseDefaultFolder => 'Use Default Folder?';
+
+ @override
+ String get setupNoFolderSelected =>
+ 'No folder selected. Would you like to use the default Music folder?';
+
+ @override
+ String get setupUseDefault => 'Use Default';
+
+ @override
+ String get setupDownloadLocationTitle => 'Download Location';
+
+ @override
+ String get setupDownloadLocationIosMessage =>
+ 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.';
+
+ @override
+ String get setupAppDocumentsFolder => 'App Documents Folder';
+
+ @override
+ String get setupAppDocumentsFolderSubtitle =>
+ 'Recommended - accessible via Files app';
+
+ @override
+ String get setupChooseFromFiles => 'Choose from Files';
+
+ @override
+ String get setupChooseFromFilesSubtitle => 'Select iCloud or other location';
+
+ @override
+ String get setupIosEmptyFolderWarning =>
+ 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
+
+ @override
+ String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
+
+ @override
+ String get setupStepStorage => 'Storage';
+
+ @override
+ String get setupStepNotification => 'Notification';
+
+ @override
+ String get setupStepFolder => 'Folder';
+
+ @override
+ String get setupStepSpotify => 'Spotify';
+
+ @override
+ String get setupStepPermission => 'Permission';
+
+ @override
+ String get setupStorageGranted => 'Storage Permission Granted!';
+
+ @override
+ String get setupStorageRequired => 'Storage Permission Required';
+
+ @override
+ String get setupStorageDescription =>
+ 'SpotiFLAC needs storage permission to save your downloaded music files.';
+
+ @override
+ String get setupNotificationGranted => 'Notification Permission Granted!';
+
+ @override
+ String get setupNotificationEnable => 'Enable Notifications';
+
+ @override
+ String get setupNotificationDescription =>
+ 'Get notified when downloads complete or require attention.';
+
+ @override
+ String get setupFolderSelected => 'Download Folder Selected!';
+
+ @override
+ String get setupFolderChoose => 'Choose Download Folder';
+
+ @override
+ String get setupFolderDescription =>
+ 'Select a folder where your downloaded music will be saved.';
+
+ @override
+ String get setupChangeFolder => 'Change Folder';
+
+ @override
+ String get setupSelectFolder => 'Select Folder';
+
+ @override
+ String get setupSpotifyApiOptional => 'Spotify API (Optional)';
+
+ @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';
+
+ @override
+ String get setupEnterCredentialsBelow => 'Enter your credentials below';
+
+ @override
+ String get setupUsingDeezer => 'Using Deezer (no account needed)';
+
+ @override
+ String get setupEnterClientId => 'Enter Spotify Client ID';
+
+ @override
+ String get setupEnterClientSecret => 'Enter Spotify Client Secret';
+
+ @override
+ String get setupGetFreeCredentials =>
+ 'Get your free API credentials from the Spotify Developer Dashboard.';
+
+ @override
+ String get setupEnableNotifications => 'Enable Notifications';
+
+ @override
+ String get setupProceedToNextStep => 'You can now proceed to the next step.';
+
+ @override
+ String get setupNotificationProgressDescription =>
+ 'You will receive download progress notifications.';
+
+ @override
+ String get setupNotificationBackgroundDescription =>
+ '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';
+
+ @override
+ String get setupBack => 'Back';
+
+ @override
+ String get setupNext => 'Next';
+
+ @override
+ String get setupGetStarted => 'Get Started';
+
+ @override
+ String get setupSkipAndStart => 'Skip & Start';
+
+ @override
+ String get setupAllowAccessToManageFiles =>
+ 'Please enable \"Allow access to manage all files\" in the next screen.';
+
+ @override
+ String get setupGetCredentialsFromSpotify =>
+ 'Get credentials from developer.spotify.com';
+
+ @override
+ String get dialogCancel => 'Cancel';
+
+ @override
+ String get dialogOk => 'OK';
+
+ @override
+ String get dialogSave => 'Save';
+
+ @override
+ String get dialogDelete => 'Delete';
+
+ @override
+ String get dialogRetry => 'Retry';
+
+ @override
+ String get dialogClose => 'Close';
+
+ @override
+ String get dialogYes => 'Yes';
+
+ @override
+ String get dialogNo => 'No';
+
+ @override
+ String get dialogClear => 'Clear';
+
+ @override
+ String get dialogConfirm => 'Confirm';
+
+ @override
+ String get dialogDone => 'Done';
+
+ @override
+ String get dialogImport => 'Import';
+
+ @override
+ String get dialogDiscard => 'Discard';
+
+ @override
+ String get dialogRemove => 'Remove';
+
+ @override
+ String get dialogUninstall => 'Uninstall';
+
+ @override
+ String get dialogDiscardChanges => 'Discard Changes?';
+
+ @override
+ String get dialogUnsavedChanges =>
+ 'You have unsaved changes. Do you want to discard them?';
+
+ @override
+ String get dialogDownloadFailed => 'Download Failed';
+
+ @override
+ String get dialogTrackLabel => 'Track:';
+
+ @override
+ String get dialogArtistLabel => 'Artist:';
+
+ @override
+ String get dialogErrorLabel => 'Error:';
+
+ @override
+ String get dialogClearAll => 'Clear All';
+
+ @override
+ String get dialogClearAllDownloads =>
+ 'Are you sure you want to clear all downloads?';
+
+ @override
+ String get dialogRemoveFromDevice => 'Remove from device?';
+
+ @override
+ String get dialogRemoveExtension => 'Remove Extension';
+
+ @override
+ String get dialogRemoveExtensionMessage =>
+ 'Are you sure you want to remove this extension? This cannot be undone.';
+
+ @override
+ String get dialogUninstallExtension => 'Uninstall Extension?';
+
+ @override
+ String dialogUninstallExtensionMessage(String extensionName) {
+ return 'Are you sure you want to remove $extensionName?';
+ }
+
+ @override
+ String get dialogClearHistoryTitle => 'Clear History';
+
+ @override
+ String get dialogClearHistoryMessage =>
+ 'Are you sure you want to clear all download history? This cannot be undone.';
+
+ @override
+ String get dialogDeleteSelectedTitle => 'Delete Selected';
+
+ @override
+ String dialogDeleteSelectedMessage(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.';
+ }
+
+ @override
+ String get dialogImportPlaylistTitle => 'Import Playlist';
+
+ @override
+ String dialogImportPlaylistMessage(int count) {
+ return 'Found $count tracks in CSV. Add them to download queue?';
+ }
+
+ @override
+ String csvImportTracks(int count) {
+ return '$count tracks from CSV';
+ }
+
+ @override
+ String snackbarAddedToQueue(String trackName) {
+ return 'Added \"$trackName\" to queue';
+ }
+
+ @override
+ String snackbarAddedTracksToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String snackbarAlreadyDownloaded(String trackName) {
+ return '\"$trackName\" already downloaded';
+ }
+
+ @override
+ String get snackbarHistoryCleared => 'History cleared';
+
+ @override
+ String get snackbarCredentialsSaved => 'Credentials saved';
+
+ @override
+ String get snackbarCredentialsCleared => 'Credentials cleared';
+
+ @override
+ String snackbarDeletedTracks(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Deleted $count $_temp0';
+ }
+
+ @override
+ String snackbarCannotOpenFile(String error) {
+ return 'Cannot open file: $error';
+ }
+
+ @override
+ String get snackbarFillAllFields => 'Please fill all fields';
+
+ @override
+ String get snackbarViewQueue => 'View Queue';
+
+ @override
+ String snackbarFailedToLoad(String error) {
+ return 'Failed to load: $error';
+ }
+
+ @override
+ String snackbarUrlCopied(String platform) {
+ return '$platform URL copied to clipboard';
+ }
+
+ @override
+ String get snackbarFileNotFound => 'File not found';
+
+ @override
+ String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file';
+
+ @override
+ String get snackbarProviderPrioritySaved => 'Provider priority saved';
+
+ @override
+ String get snackbarMetadataProviderSaved =>
+ 'Metadata provider priority saved';
+
+ @override
+ String snackbarExtensionInstalled(String extensionName) {
+ return '$extensionName installed.';
+ }
+
+ @override
+ String snackbarExtensionUpdated(String extensionName) {
+ return '$extensionName updated.';
+ }
+
+ @override
+ String get snackbarFailedToInstall => 'Failed to install extension';
+
+ @override
+ String get snackbarFailedToUpdate => 'Failed to update extension';
+
+ @override
+ String get errorRateLimited => 'Rate Limited';
+
+ @override
+ String get errorRateLimitedMessage =>
+ 'Too many requests. Please wait a moment before searching again.';
+
+ @override
+ String errorFailedToLoad(String item) {
+ return 'Failed to load $item';
+ }
+
+ @override
+ String get errorNoTracksFound => 'No tracks found';
+
+ @override
+ String errorMissingExtensionSource(String item) {
+ return 'Cannot load $item: missing extension source';
+ }
+
+ @override
+ String get statusQueued => 'Queued';
+
+ @override
+ String get statusDownloading => 'Downloading';
+
+ @override
+ String get statusFinalizing => 'Finalizing';
+
+ @override
+ String get statusCompleted => 'Completed';
+
+ @override
+ String get statusFailed => 'Failed';
+
+ @override
+ String get statusSkipped => 'Skipped';
+
+ @override
+ String get statusPaused => 'Paused';
+
+ @override
+ String get actionPause => 'Pause';
+
+ @override
+ String get actionResume => 'Resume';
+
+ @override
+ String get actionCancel => 'Cancel';
+
+ @override
+ String get actionStop => 'Stop';
+
+ @override
+ String get actionSelect => 'Select';
+
+ @override
+ String get actionSelectAll => 'Select All';
+
+ @override
+ String get actionDeselect => 'Deselect';
+
+ @override
+ String get actionPaste => 'Paste';
+
+ @override
+ String get actionImportCsv => 'Import CSV';
+
+ @override
+ String get actionRemoveCredentials => 'Remove Credentials';
+
+ @override
+ String get actionSaveCredentials => 'Save Credentials';
+
+ @override
+ String selectionSelected(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get selectionAllSelected => 'All tracks selected';
+
+ @override
+ String get selectionTapToSelect => 'Tap tracks to select';
+
+ @override
+ String selectionDeleteTracks(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Delete $count $_temp0';
+ }
+
+ @override
+ String get selectionSelectToDelete => 'Select tracks to delete';
+
+ @override
+ String progressFetchingMetadata(int current, int total) {
+ return 'Fetching metadata... $current/$total';
+ }
+
+ @override
+ String get progressReadingCsv => 'Reading CSV...';
+
+ @override
+ String get searchSongs => 'Songs';
+
+ @override
+ String get searchArtists => 'Artists';
+
+ @override
+ String get searchAlbums => 'Albums';
+
+ @override
+ String get searchPlaylists => 'Playlists';
+
+ @override
+ String get tooltipPlay => 'Play';
+
+ @override
+ String get tooltipCancel => 'Cancel';
+
+ @override
+ String get tooltipStop => 'Stop';
+
+ @override
+ String get tooltipRetry => 'Retry';
+
+ @override
+ String get tooltipRemove => 'Remove';
+
+ @override
+ String get tooltipClear => 'Clear';
+
+ @override
+ String get tooltipPaste => 'Paste';
+
+ @override
+ String get filenameFormat => 'Filename Format';
+
+ @override
+ String filenameFormatPreview(String preview) {
+ return 'Preview: $preview';
+ }
+
+ @override
+ String get filenameAvailablePlaceholders => 'Available placeholders:';
+
+ @override
+ String filenameHint(Object artist, Object title) {
+ return '$artist - $title';
+ }
+
+ @override
+ String get folderOrganization => 'Folder Organization';
+
+ @override
+ String get folderOrganizationNone => 'No organization';
+
+ @override
+ String get folderOrganizationByArtist => 'By Artist';
+
+ @override
+ String get folderOrganizationByAlbum => 'By Album';
+
+ @override
+ String get folderOrganizationByArtistAlbum => 'Artist/Album';
+
+ @override
+ String get folderOrganizationDescription =>
+ 'Organize downloaded files into folders';
+
+ @override
+ String get folderOrganizationNoneSubtitle => 'All files in download folder';
+
+ @override
+ String get folderOrganizationByArtistSubtitle =>
+ 'Separate folder for each artist';
+
+ @override
+ String get folderOrganizationByAlbumSubtitle =>
+ 'Separate folder for each album';
+
+ @override
+ String get folderOrganizationByArtistAlbumSubtitle =>
+ 'Nested folders for artist and album';
+
+ @override
+ String get updateAvailable => 'Update Available';
+
+ @override
+ String updateNewVersion(String version) {
+ return 'Version $version is available';
+ }
+
+ @override
+ String get updateDownload => 'Download';
+
+ @override
+ String get updateLater => 'Later';
+
+ @override
+ String get updateChangelog => 'Changelog';
+
+ @override
+ String get updateStartingDownload => 'Starting download...';
+
+ @override
+ String get updateDownloadFailed => 'Download failed';
+
+ @override
+ String get updateFailedMessage => 'Failed to download update';
+
+ @override
+ String get updateNewVersionReady => 'A new version is ready';
+
+ @override
+ String get updateCurrent => 'Current';
+
+ @override
+ String get updateNew => 'New';
+
+ @override
+ String get updateDownloading => 'Downloading...';
+
+ @override
+ String get updateWhatsNew => 'What\'s New';
+
+ @override
+ String get updateDownloadInstall => 'Download & Install';
+
+ @override
+ String get updateDontRemind => 'Don\'t remind';
+
+ @override
+ String get providerPriority => 'Provider Priority';
+
+ @override
+ String get providerPrioritySubtitle => 'Drag to reorder download providers';
+
+ @override
+ String get providerPriorityTitle => 'Provider Priority';
+
+ @override
+ String get providerPriorityDescription =>
+ 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.';
+
+ @override
+ String get providerPriorityInfo =>
+ 'If a track is not available on the first provider, the app will automatically try the next one.';
+
+ @override
+ String get providerBuiltIn => 'Built-in';
+
+ @override
+ String get providerExtension => 'Extension';
+
+ @override
+ String get metadataProviderPriority => 'Metadata Provider Priority';
+
+ @override
+ String get metadataProviderPrioritySubtitle =>
+ 'Order used when fetching track metadata';
+
+ @override
+ String get metadataProviderPriorityTitle => 'Metadata Priority';
+
+ @override
+ String get metadataProviderPriorityDescription =>
+ 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.';
+
+ @override
+ String get metadataProviderPriorityInfo =>
+ 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.';
+
+ @override
+ String get metadataNoRateLimits => 'No rate limits';
+
+ @override
+ String get metadataMayRateLimit => 'May rate limit';
+
+ @override
+ String get logTitle => 'Logs';
+
+ @override
+ String get logCopy => 'Copy Logs';
+
+ @override
+ String get logClear => 'Clear Logs';
+
+ @override
+ String get logShare => 'Share Logs';
+
+ @override
+ String get logEmpty => 'No logs yet';
+
+ @override
+ String get logCopied => 'Logs copied to clipboard';
+
+ @override
+ String get logSearchHint => 'Search logs...';
+
+ @override
+ String get logFilterLevel => 'Level';
+
+ @override
+ String get logFilterSection => 'Filter';
+
+ @override
+ String get logShareLogs => 'Share logs';
+
+ @override
+ String get logClearLogs => 'Clear logs';
+
+ @override
+ String get logClearLogsTitle => 'Clear Logs';
+
+ @override
+ String get logClearLogsMessage => 'Are you sure you want to clear all logs?';
+
+ @override
+ String get logIspBlocking => 'ISP BLOCKING DETECTED';
+
+ @override
+ String get logRateLimited => 'RATE LIMITED';
+
+ @override
+ String get logNetworkError => 'NETWORK ERROR';
+
+ @override
+ String get logTrackNotFound => 'TRACK NOT FOUND';
+
+ @override
+ String get logFilterBySeverity => 'Filter logs by severity';
+
+ @override
+ String get logNoLogsYet => 'No logs yet';
+
+ @override
+ String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app';
+
+ @override
+ String get logIssueSummary => 'Issue Summary';
+
+ @override
+ String get logIspBlockingDescription =>
+ 'Your ISP may be blocking access to download services';
+
+ @override
+ String get logIspBlockingSuggestion =>
+ 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8';
+
+ @override
+ String get logRateLimitedDescription => 'Too many requests to the service';
+
+ @override
+ String get logRateLimitedSuggestion =>
+ 'Wait a few minutes before trying again';
+
+ @override
+ String get logNetworkErrorDescription => 'Connection issues detected';
+
+ @override
+ String get logNetworkErrorSuggestion => 'Check your internet connection';
+
+ @override
+ String get logTrackNotFoundDescription =>
+ 'Some tracks could not be found on download services';
+
+ @override
+ String get logTrackNotFoundSuggestion =>
+ 'The track may not be available in lossless quality';
+
+ @override
+ String logTotalErrors(int count) {
+ return 'Total errors: $count';
+ }
+
+ @override
+ String logAffected(String domains) {
+ return 'Affected: $domains';
+ }
+
+ @override
+ String logEntriesFiltered(int count) {
+ return 'Entries ($count filtered)';
+ }
+
+ @override
+ String logEntries(int count) {
+ return 'Entries ($count)';
+ }
+
+ @override
+ String get credentialsTitle => 'Spotify Credentials';
+
+ @override
+ String get credentialsDescription =>
+ 'Enter your Client ID and Secret to use your own Spotify application quota.';
+
+ @override
+ String get credentialsClientId => 'Client ID';
+
+ @override
+ String get credentialsClientIdHint => 'Paste Client ID';
+
+ @override
+ String get credentialsClientSecret => 'Client Secret';
+
+ @override
+ String get credentialsClientSecretHint => 'Paste Client Secret';
+
+ @override
+ String get channelStable => 'Stable';
+
+ @override
+ String get channelPreview => 'Preview';
+
+ @override
+ String get sectionSearchSource => 'Search Source';
+
+ @override
+ String get sectionDownload => 'Download';
+
+ @override
+ String get sectionPerformance => 'Performance';
+
+ @override
+ String get sectionApp => 'App';
+
+ @override
+ String get sectionData => 'Data';
+
+ @override
+ String get sectionDebug => 'Debug';
+
+ @override
+ String get sectionService => 'Service';
+
+ @override
+ String get sectionAudioQuality => 'Audio Quality';
+
+ @override
+ String get sectionFileSettings => 'File Settings';
+
+ @override
+ String get sectionLyrics => 'Lyrics';
+
+ @override
+ String get lyricsMode => 'Lyrics Mode';
+
+ @override
+ String get lyricsModeDescription =>
+ 'Choose how lyrics are saved with your downloads';
+
+ @override
+ String get lyricsModeEmbed => 'Embed in file';
+
+ @override
+ String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
+
+ @override
+ String get lyricsModeExternal => 'External .lrc file';
+
+ @override
+ String get lyricsModeExternalSubtitle =>
+ 'Separate .lrc file for players like Samsung Music';
+
+ @override
+ String get lyricsModeBoth => 'Both';
+
+ @override
+ String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
+
+ @override
+ String get sectionColor => 'Color';
+
+ @override
+ String get sectionTheme => 'Theme';
+
+ @override
+ String get sectionLayout => 'Layout';
+
+ @override
+ String get sectionLanguage => 'Language';
+
+ @override
+ String get appearanceLanguage => 'App Language';
+
+ @override
+ String get appearanceLanguageSubtitle => 'Choose your preferred language';
+
+ @override
+ String get settingsAppearanceSubtitle => 'Theme, colors, display';
+
+ @override
+ String get settingsDownloadSubtitle => 'Service, quality, filename format';
+
+ @override
+ String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
+
+ @override
+ String get settingsExtensionsSubtitle => 'Manage download providers';
+
+ @override
+ String get settingsLogsSubtitle => 'View app logs for debugging';
+
+ @override
+ String get loadingSharedLink => 'Loading shared link...';
+
+ @override
+ String get pressBackAgainToExit => 'Press back again to exit';
+
+ @override
+ String get tracksHeader => 'Tracks';
+
+ @override
+ String downloadAllCount(int count) {
+ return 'Download All ($count)';
+ }
+
+ @override
+ String tracksCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get trackCopyFilePath => 'Copy file path';
+
+ @override
+ String get trackRemoveFromDevice => 'Remove from device';
+
+ @override
+ String get trackLoadLyrics => 'Load Lyrics';
+
+ @override
+ String get trackMetadata => 'Metadata';
+
+ @override
+ String get trackFileInfo => 'File Info';
+
+ @override
+ String get trackLyrics => 'Lyrics';
+
+ @override
+ String get trackFileNotFound => 'File not found';
+
+ @override
+ String get trackOpenInDeezer => 'Open in Deezer';
+
+ @override
+ String get trackOpenInSpotify => 'Open in Spotify';
+
+ @override
+ String get trackTrackName => 'Track name';
+
+ @override
+ String get trackArtist => 'Artist';
+
+ @override
+ String get trackAlbumArtist => 'Album artist';
+
+ @override
+ String get trackAlbum => 'Album';
+
+ @override
+ String get trackTrackNumber => 'Track number';
+
+ @override
+ String get trackDiscNumber => 'Disc number';
+
+ @override
+ String get trackDuration => 'Duration';
+
+ @override
+ String get trackAudioQuality => 'Audio quality';
+
+ @override
+ String get trackReleaseDate => 'Release date';
+
+ @override
+ String get trackGenre => 'Genre';
+
+ @override
+ String get trackLabel => 'Label';
+
+ @override
+ String get trackCopyright => 'Copyright';
+
+ @override
+ String get trackDownloaded => 'Downloaded';
+
+ @override
+ String get trackCopyLyrics => 'Copy lyrics';
+
+ @override
+ String get trackLyricsNotAvailable => 'Lyrics not available for this track';
+
+ @override
+ String get trackLyricsTimeout => 'Request timed out. Try again later.';
+
+ @override
+ String get trackLyricsLoadFailed => 'Failed to load lyrics';
+
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
+ @override
+ String get trackCopiedToClipboard => 'Copied to clipboard';
+
+ @override
+ String get trackDeleteConfirmTitle => 'Remove from device?';
+
+ @override
+ String get trackDeleteConfirmMessage =>
+ 'This will permanently delete the downloaded file and remove it from your history.';
+
+ @override
+ String trackCannotOpen(String message) {
+ return 'Cannot open: $message';
+ }
+
+ @override
+ String get dateToday => 'Today';
+
+ @override
+ String get dateYesterday => 'Yesterday';
+
+ @override
+ String dateDaysAgo(int count) {
+ return '$count days ago';
+ }
+
+ @override
+ String dateWeeksAgo(int count) {
+ return '$count weeks ago';
+ }
+
+ @override
+ String dateMonthsAgo(int count) {
+ return '$count months ago';
+ }
+
+ @override
+ String get concurrentSequential => 'Sequential';
+
+ @override
+ String get concurrentParallel2 => '2 Parallel';
+
+ @override
+ String get concurrentParallel3 => '3 Parallel';
+
+ @override
+ String get tapToSeeError => 'Tap to see error details';
+
+ @override
+ String get storeFilterAll => 'All';
+
+ @override
+ String get storeFilterMetadata => 'Metadata';
+
+ @override
+ String get storeFilterDownload => 'Download';
+
+ @override
+ String get storeFilterUtility => 'Utility';
+
+ @override
+ String get storeFilterLyrics => 'Lyrics';
+
+ @override
+ String get storeFilterIntegration => 'Integration';
+
+ @override
+ String get storeClearFilters => 'Clear filters';
+
+ @override
+ String get storeNoResults => 'No extensions found';
+
+ @override
+ String get extensionProviderPriority => 'Provider Priority';
+
+ @override
+ String get extensionInstallButton => 'Install Extension';
+
+ @override
+ String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
+
+ @override
+ String get extensionDefaultProviderSubtitle => 'Use built-in search';
+
+ @override
+ String get extensionAuthor => 'Author';
+
+ @override
+ String get extensionId => 'ID';
+
+ @override
+ String get extensionError => 'Error';
+
+ @override
+ String get extensionCapabilities => 'Capabilities';
+
+ @override
+ String get extensionMetadataProvider => 'Metadata Provider';
+
+ @override
+ String get extensionDownloadProvider => 'Download Provider';
+
+ @override
+ String get extensionLyricsProvider => 'Lyrics Provider';
+
+ @override
+ String get extensionUrlHandler => 'URL Handler';
+
+ @override
+ String get extensionQualityOptions => 'Quality Options';
+
+ @override
+ String get extensionPostProcessingHooks => 'Post-Processing Hooks';
+
+ @override
+ String get extensionPermissions => 'Permissions';
+
+ @override
+ String get extensionSettings => 'Settings';
+
+ @override
+ String get extensionRemoveButton => 'Remove Extension';
+
+ @override
+ String get extensionUpdated => 'Updated';
+
+ @override
+ String get extensionMinAppVersion => 'Min App Version';
+
+ @override
+ String get extensionCustomTrackMatching => 'Custom Track Matching';
+
+ @override
+ String get extensionPostProcessing => 'Post-Processing';
+
+ @override
+ String extensionHooksAvailable(int count) {
+ return '$count hook(s) available';
+ }
+
+ @override
+ String extensionPatternsCount(int count) {
+ return '$count pattern(s)';
+ }
+
+ @override
+ String extensionStrategy(String strategy) {
+ return 'Strategy: $strategy';
+ }
+
+ @override
+ String get extensionsProviderPrioritySection => 'Provider Priority';
+
+ @override
+ String get extensionsInstalledSection => 'Installed Extensions';
+
+ @override
+ String get extensionsNoExtensions => 'No extensions installed';
+
+ @override
+ String get extensionsNoExtensionsSubtitle =>
+ 'Install .spotiflac-ext files to add new providers';
+
+ @override
+ String get extensionsInstallButton => 'Install Extension';
+
+ @override
+ String get extensionsInfoTip =>
+ 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.';
+
+ @override
+ String get extensionsInstalledSuccess => 'Extension installed successfully';
+
+ @override
+ String get extensionsDownloadPriority => 'Download Priority';
+
+ @override
+ String get extensionsDownloadPrioritySubtitle => 'Set download service order';
+
+ @override
+ String get extensionsNoDownloadProvider =>
+ 'No extensions with download provider';
+
+ @override
+ String get extensionsMetadataPriority => 'Metadata Priority';
+
+ @override
+ String get extensionsMetadataPrioritySubtitle =>
+ 'Set search & metadata source order';
+
+ @override
+ String get extensionsNoMetadataProvider =>
+ 'No extensions with metadata provider';
+
+ @override
+ String get extensionsSearchProvider => 'Search Provider';
+
+ @override
+ String get extensionsNoCustomSearch => 'No extensions with custom search';
+
+ @override
+ String get extensionsSearchProviderDescription =>
+ 'Choose which service to use for searching tracks';
+
+ @override
+ String get extensionsCustomSearch => 'Custom search';
+
+ @override
+ String get extensionsErrorLoading => 'Error loading extension';
+
+ @override
+ String get qualityFlacLossless => 'FLAC Lossless';
+
+ @override
+ String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz';
+
+ @override
+ String get qualityHiResFlac => 'Hi-Res FLAC';
+
+ @override
+ String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz';
+
+ @override
+ String get qualityHiResFlacMax => 'Hi-Res FLAC Max';
+
+ @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';
+
+ @override
+ String get downloadAskBeforeDownload => 'Ask Before Download';
+
+ @override
+ String get downloadDirectory => 'Download Directory';
+
+ @override
+ String get downloadSeparateSinglesFolder => 'Separate Singles Folder';
+
+ @override
+ String get downloadAlbumFolderStructure => 'Album Folder Structure';
+
+ @override
+ String get downloadSaveFormat => 'Save Format';
+
+ @override
+ String get downloadSelectService => 'Select Service';
+
+ @override
+ String get downloadSelectQuality => 'Select Quality';
+
+ @override
+ String get downloadFrom => 'Download From';
+
+ @override
+ String get downloadDefaultQualityLabel => 'Default Quality';
+
+ @override
+ String get downloadBestAvailable => 'Best available';
+
+ @override
+ String get folderNone => 'None';
+
+ @override
+ String get folderNoneSubtitle => 'Save all files directly to download folder';
+
+ @override
+ String get folderArtist => 'Artist';
+
+ @override
+ String get folderArtistSubtitle => 'Artist Name/filename';
+
+ @override
+ String get folderAlbum => 'Album';
+
+ @override
+ String get folderAlbumSubtitle => 'Album Name/filename';
+
+ @override
+ String get folderArtistAlbum => 'Artist/Album';
+
+ @override
+ String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename';
+
+ @override
+ String get serviceTidal => 'Tidal';
+
+ @override
+ String get serviceQobuz => 'Qobuz';
+
+ @override
+ String get serviceAmazon => 'Amazon';
+
+ @override
+ String get serviceDeezer => 'Deezer';
+
+ @override
+ String get serviceSpotify => 'Spotify';
+
+ @override
+ String get appearanceAmoledDark => 'AMOLED Dark';
+
+ @override
+ String get appearanceAmoledDarkSubtitle => 'Pure black background';
+
+ @override
+ String get appearanceChooseAccentColor => 'Choose Accent Color';
+
+ @override
+ String get appearanceChooseTheme => 'Theme Mode';
+
+ @override
+ String get queueTitle => 'Download Queue';
+
+ @override
+ String get queueClearAll => 'Clear All';
+
+ @override
+ String get queueClearAllMessage =>
+ 'Are you sure you want to clear all downloads?';
+
+ @override
+ String get queueEmpty => 'No downloads in queue';
+
+ @override
+ String get queueEmptySubtitle => 'Add tracks from the home screen';
+
+ @override
+ String get queueClearCompleted => 'Clear completed';
+
+ @override
+ String get queueDownloadFailed => 'Download Failed';
+
+ @override
+ String get queueTrackLabel => 'Track:';
+
+ @override
+ String get queueArtistLabel => 'Artist:';
+
+ @override
+ String get queueErrorLabel => 'Error:';
+
+ @override
+ String get queueUnknownError => 'Unknown error';
+
+ @override
+ String get albumFolderArtistAlbum => 'Artist / Album';
+
+ @override
+ String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
+
+ @override
+ String get albumFolderArtistYearAlbum => 'Artist / [Year] Album';
+
+ @override
+ String get albumFolderArtistYearAlbumSubtitle =>
+ 'Albums/Artist Name/[2005] Album Name/';
+
+ @override
+ String get albumFolderAlbumOnly => 'Album Only';
+
+ @override
+ String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/';
+
+ @override
+ String get albumFolderYearAlbum => '[Year] Album';
+
+ @override
+ String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
+ @override
+ String get downloadedAlbumDeleteSelected => 'Delete Selected';
+
+ @override
+ String downloadedAlbumDeleteMessage(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.';
+ }
+
+ @override
+ String get downloadedAlbumTracksHeader => 'Tracks';
+
+ @override
+ String downloadedAlbumDownloadedCount(int count) {
+ return '$count downloaded';
+ }
+
+ @override
+ String downloadedAlbumSelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get downloadedAlbumAllSelected => 'All tracks selected';
+
+ @override
+ String get downloadedAlbumTapToSelect => 'Tap tracks to select';
+
+ @override
+ String downloadedAlbumDeleteCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Delete $count $_temp0';
+ }
+
+ @override
+ String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
+
+ @override
+ String downloadedAlbumDiscHeader(int discNumber) {
+ return 'Disc $discNumber';
+ }
+
+ @override
+ String get utilityFunctions => 'Utility Functions';
+
+ @override
+ String get recentTypeArtist => 'Artist';
+
+ @override
+ String get recentTypeAlbum => 'Album';
+
+ @override
+ String get recentTypeSong => 'Song';
+
+ @override
+ String get recentTypePlaylist => 'Playlist';
+
+ @override
+ String recentPlaylistInfo(String name) {
+ return 'Playlist: $name';
+ }
+
+ @override
+ String errorGeneric(String message) {
+ return 'Error: $message';
+ }
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
+}
diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart
index 30835ee2..6b15a933 100644
--- a/lib/l10n/app_localizations_zh.dart
+++ b/lib/l10n/app_localizations_zh.dart
@@ -109,6 +109,9 @@ class AppLocalizationsZh extends AppLocalizations {
String get historyNoSinglesSubtitle =>
'Single track downloads will appear here';
+ @override
+ String get historySearchHint => 'Search history...';
+
@override
String get settingsTitle => 'Settings';
@@ -429,6 +432,21 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
@override
String get aboutSupport => 'Support';
@@ -1600,6 +1618,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
@override
String get trackCopiedToClipboard => 'Copied to clipboard';
@@ -1988,6 +2015,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2064,6 +2098,72 @@ class AppLocalizationsZh extends AppLocalizations {
String errorGeneric(String message) {
return 'Error: $message';
}
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
}
/// The translations for Chinese, as used in China (`zh_CN`).
diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb
index fce509d7..4dd87f36 100644
--- a/lib/l10n/arb/app_en.arb
+++ b/lib/l10n/arb/app_en.arb
@@ -75,8 +75,10 @@
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
"historyNoSingles": "No single downloads",
"@historyNoSingles": {"description": "Empty state when filtering singles"},
- "historyNoSinglesSubtitle": "Single track downloads will appear here",
+"historyNoSinglesSubtitle": "Single track downloads will appear here",
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
+ "historySearchHint": "Search history...",
+ "@historySearchHint": {"description": "Search bar placeholder in history"},
"settingsTitle": "Settings",
"@settingsTitle": {"description": "Settings screen title"},
@@ -304,10 +306,20 @@
"@aboutReportIssue": {"description": "Link to report bugs"},
"aboutReportIssueSubtitle": "Report any problems you encounter",
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
- "aboutFeatureRequest": "Feature request",
+"aboutFeatureRequest": "Feature request",
"@aboutFeatureRequest": {"description": "Link to suggest features"},
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
"@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"},
+ "aboutTelegramChannel": "Telegram Channel",
+ "@aboutTelegramChannel": {"description": "Link to Telegram channel"},
+ "aboutTelegramChannelSubtitle": "Announcements and updates",
+ "@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"},
+ "aboutTelegramChat": "Telegram Community",
+ "@aboutTelegramChat": {"description": "Link to Telegram chat group"},
+ "aboutTelegramChatSubtitle": "Chat with other users",
+ "@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"},
+ "aboutSocial": "Social",
+ "@aboutSocial": {"description": "Section for social links"},
"aboutSupport": "Support",
"@aboutSupport": {"description": "Section for support/donation links"},
"aboutBuyMeCoffee": "Buy me a coffee",
@@ -1176,6 +1188,12 @@
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
"trackLyricsLoadFailed": "Failed to load lyrics",
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
+ "trackEmbedLyrics": "Embed Lyrics",
+ "@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"},
+ "trackLyricsEmbedded": "Lyrics embedded successfully",
+ "@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"},
+ "trackInstrumental": "Instrumental track",
+ "@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"},
"trackCopiedToClipboard": "Copied to clipboard",
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
"trackDeleteConfirmTitle": "Remove from device?",
@@ -1465,6 +1483,10 @@
"@albumFolderYearAlbum": {"description": "Album folder option with year"},
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
+ "albumFolderArtistAlbumSingles": "Artist / Album + Singles",
+ "@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"},
+ "albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
+ "@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"},
"downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
@@ -1537,5 +1559,80 @@
"placeholders": {
"message": {"type": "String", "description": "Error message"}
}
- }
+ },
+
+ "discographyDownload": "Download Discography",
+ "@discographyDownload": {"description": "Button - download artist discography"},
+ "discographyDownloadAll": "Download All",
+ "@discographyDownloadAll": {"description": "Option - download entire discography"},
+ "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
+ "@discographyDownloadAllSubtitle": {
+ "description": "Subtitle showing total tracks and albums",
+ "placeholders": {
+ "count": {"type": "int"},
+ "albumCount": {"type": "int"}
+ }
+ },
+ "discographyAlbumsOnly": "Albums Only",
+ "@discographyAlbumsOnly": {"description": "Option - download only albums"},
+ "discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
+ "@discographyAlbumsOnlySubtitle": {
+ "description": "Subtitle showing album tracks count",
+ "placeholders": {
+ "count": {"type": "int"},
+ "albumCount": {"type": "int"}
+ }
+ },
+ "discographySinglesOnly": "Singles & EPs Only",
+ "@discographySinglesOnly": {"description": "Option - download only singles"},
+ "discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
+ "@discographySinglesOnlySubtitle": {
+ "description": "Subtitle showing singles tracks count",
+ "placeholders": {
+ "count": {"type": "int"},
+ "albumCount": {"type": "int"}
+ }
+ },
+ "discographySelectAlbums": "Select Albums...",
+ "@discographySelectAlbums": {"description": "Option - manually select albums to download"},
+ "discographySelectAlbumsSubtitle": "Choose specific albums or singles",
+ "@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"},
+ "discographyFetchingTracks": "Fetching tracks...",
+ "@discographyFetchingTracks": {"description": "Progress - fetching album tracks"},
+ "discographyFetchingAlbum": "Fetching {current} of {total}...",
+ "@discographyFetchingAlbum": {
+ "description": "Progress - fetching specific album",
+ "placeholders": {
+ "current": {"type": "int"},
+ "total": {"type": "int"}
+ }
+ },
+ "discographySelectedCount": "{count} selected",
+ "@discographySelectedCount": {
+ "description": "Selection count badge",
+ "placeholders": {
+ "count": {"type": "int"}
+ }
+ },
+ "discographyDownloadSelected": "Download Selected",
+ "@discographyDownloadSelected": {"description": "Button - download selected albums"},
+ "discographyAddedToQueue": "Added {count} tracks to queue",
+ "@discographyAddedToQueue": {
+ "description": "Snackbar - tracks added from discography",
+ "placeholders": {
+ "count": {"type": "int"}
+ }
+ },
+ "discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
+ "@discographySkippedDownloaded": {
+ "description": "Snackbar - with skipped tracks count",
+ "placeholders": {
+ "added": {"type": "int"},
+ "skipped": {"type": "int"}
+ }
+ },
+ "discographyNoAlbums": "No albums available",
+ "@discographyNoAlbums": {"description": "Error - no albums found for artist"},
+ "discographyFailedToFetch": "Failed to fetch some albums",
+ "@discographyFailedToFetch": {"description": "Error - some albums failed to load"}
}
diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb
index b451b4a5..f9e67d0b 100644
--- a/lib/l10n/arb/app_es_ES.arb
+++ b/lib/l10n/arb/app_es_ES.arb
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
- "historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
+ "historyTracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
- "historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}",
+ "historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbumes}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": {
"description": "Album screen title"
},
- "albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
+ "albumTracks": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": {
"description": "Section header for compilations"
},
- "artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}",
+ "artistReleases": "{count, plural, =1{1 lanzamiento} other{{count} lanzamientos}}",
"@artistReleases": {
"description": "Artist release count",
"placeholders": {
@@ -1108,7 +1108,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
- "dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
+ "dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -1169,7 +1169,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
- "snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}",
+ "snackbarDeletedTracks": "Eliminado {count} {count, plural, =1{pista} other{pistas}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": {
"description": "Hint - how to select items"
},
- "selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
+ "selectionDeleteTracks": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
"@selectionDeleteTracks": {
"description": "Delete button with count",
"placeholders": {
@@ -1916,7 +1916,7 @@
}
}
},
- "tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
+ "tracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
@@ -2520,7 +2520,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
- "downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
+ "downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -2559,7 +2559,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
- "downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
+ "downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb
index 5c97f0de..da495241 100644
--- a/lib/l10n/arb/app_id.arb
+++ b/lib/l10n/arb/app_id.arb
@@ -683,5 +683,23 @@
"recentTypePlaylist": "Playlist",
"recentPlaylistInfo": "Playlist: {name}",
- "errorGeneric": "Error: {message}"
+ "errorGeneric": "Error: {message}",
+
+ "discographyDownload": "Unduh Diskografi",
+ "discographyDownloadAll": "Unduh Semua",
+ "discographyDownloadAllSubtitle": "{count} lagu dari {albumCount} rilis",
+ "discographyAlbumsOnly": "Album Saja",
+ "discographyAlbumsOnlySubtitle": "{count} lagu dari {albumCount} album",
+ "discographySinglesOnly": "Single & EP Saja",
+ "discographySinglesOnlySubtitle": "{count} lagu dari {albumCount} single",
+ "discographySelectAlbums": "Pilih Album...",
+ "discographySelectAlbumsSubtitle": "Pilih album atau single tertentu",
+ "discographyFetchingTracks": "Mengambil lagu...",
+ "discographyFetchingAlbum": "Mengambil {current} dari {total}...",
+ "discographySelectedCount": "{count} dipilih",
+ "discographyDownloadSelected": "Unduh yang Dipilih",
+ "discographyAddedToQueue": "Menambahkan {count} lagu ke antrian",
+ "discographySkippedDownloaded": "{added} ditambahkan, {skipped} sudah diunduh",
+ "discographyNoAlbums": "Tidak ada album tersedia",
+ "discographyFailedToFetch": "Gagal mengambil beberapa album"
}
diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb
index cc4dbc02..7f8d81a2 100644
--- a/lib/l10n/arb/app_pt_PT.arb
+++ b/lib/l10n/arb/app_pt_PT.arb
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
- "historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
+ "historyTracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
- "historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}",
+ "historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbuns}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": {
"description": "Album screen title"
},
- "albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
+ "albumTracks": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": {
"description": "Section header for compilations"
},
- "artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}",
+ "artistReleases": "{count, plural, =1{1 lançamento} other{{count} lançamentos}}",
"@artistReleases": {
"description": "Artist release count",
"placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": {
"description": "Hint - how to select items"
},
- "selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}",
+ "selectionDeleteTracks": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
"@selectionDeleteTracks": {
"description": "Delete button with count",
"placeholders": {
@@ -1916,7 +1916,7 @@
}
}
},
- "tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
+ "tracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb
new file mode 100644
index 00000000..79d419e9
--- /dev/null
+++ b/lib/l10n/arb/app_tr.arb
@@ -0,0 +1,7 @@
+{
+ "@@locale": "tr",
+ "@@last_modified": "2026-01-21",
+
+ "appName": "SpotiFLAC",
+ "@appName": {"description": "App name - DO NOT TRANSLATE"}
+}
diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart
index 989cee9d..37da93dc 100644
--- a/lib/providers/download_queue_provider.dart
+++ b/lib/providers/download_queue_provider.dart
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
+import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('DownloadQueue');
@@ -130,15 +131,36 @@ class DownloadHistoryItem {
class DownloadHistoryState {
final List items;
final Set _downloadedSpotifyIds;
+ final Map _bySpotifyId;
+ final Map _byIsrc;
DownloadHistoryState({this.items = const []})
: _downloadedSpotifyIds = items
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
.map((item) => item.spotifyId!)
- .toSet();
+ .toSet(),
+ _bySpotifyId = Map.fromEntries(
+ items
+ .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
+ .map((item) => MapEntry(item.spotifyId!, item)),
+ ),
+ _byIsrc = Map.fromEntries(
+ items
+ .where((item) => item.isrc != null && item.isrc!.isNotEmpty)
+ .map((item) => MapEntry(item.isrc!, item)),
+ );
+ /// O(1) check if spotify_id exists
bool isDownloaded(String spotifyId) =>
_downloadedSpotifyIds.contains(spotifyId);
+
+ /// O(1) lookup by spotify_id
+ DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
+ _bySpotifyId[spotifyId];
+
+ /// O(1) lookup by ISRC
+ DownloadHistoryItem? getByIsrc(String isrc) =>
+ _byIsrc[isrc];
DownloadHistoryState copyWith({List? items}) {
return DownloadHistoryState(items: items ?? this.items);
@@ -146,130 +168,66 @@ class DownloadHistoryState {
}
class DownloadHistoryNotifier extends Notifier {
- static const _storageKey = 'download_history';
- final Future _prefs = SharedPreferences.getInstance();
+ final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
@override
DownloadHistoryState build() {
- _loadFromStorageSync();
+ _loadFromDatabaseSync();
return DownloadHistoryState();
}
/// Synchronously schedule load - ensures it runs before any UI renders
- void _loadFromStorageSync() {
+ void _loadFromDatabaseSync() {
if (_isLoaded) return;
+ _isLoaded = true;
Future.microtask(() async {
- await _loadFromStorage();
- _isLoaded = true;
+ await _loadFromDatabase();
});
}
- Future _loadFromStorage() async {
+ Future _loadFromDatabase() async {
try {
- final prefs = await _prefs;
- final jsonStr = prefs.getString(_storageKey);
- if (jsonStr != null && jsonStr.isNotEmpty) {
- final List jsonList = jsonDecode(jsonStr);
- final items = jsonList
- .map((e) => DownloadHistoryItem.fromJson(e as Map))
- .toList();
-
- final deduplicatedItems = _deduplicateHistory(items);
-
- state = state.copyWith(items: deduplicatedItems);
- _historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
-
- if (deduplicatedItems.length < items.length) {
- _historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
- await _saveToStorage();
- }
- } else {
- _historyLog.d('No history found in storage');
- }
- } catch (e) {
- _historyLog.e('Failed to load history: $e');
- }
- }
-
- /// Keeps the most recent entry (first occurrence since list is sorted by date desc)
- List _deduplicateHistory(List items) {
- final seen = {}; // key -> index of first occurrence
- final result = [];
-
- for (int i = 0; i < items.length; i++) {
- final item = items[i];
- String? key;
-
- if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
- if (item.spotifyId!.startsWith('deezer:')) {
- key = 'deezer:${item.spotifyId!.substring(7)}';
- } else {
- key = 'spotify:${item.spotifyId}';
- }
- } else if (item.isrc != null && item.isrc!.isNotEmpty) {
- key = 'isrc:${item.isrc}';
+ final migrated = await _db.migrateFromSharedPreferences();
+ if (migrated) {
+ _historyLog.i('Migrated history from SharedPreferences to SQLite');
}
- if (key != null) {
- if (!seen.containsKey(key)) {
- seen[key] = result.length;
- result.add(item);
- } else {
- _historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
+ // Migrate iOS paths if container UUID changed after app update
+ if (Platform.isIOS) {
+ final pathsMigrated = await _db.migrateIosContainerPaths();
+ if (pathsMigrated) {
+ _historyLog.i('Migrated iOS container paths after app update');
}
- } else {
- result.add(item);
}
- }
-
- return result;
- }
-
- Future _saveToStorage() async {
- try {
- final prefs = await _prefs;
- final jsonList = state.items.map((e) => e.toJson()).toList();
- await prefs.setString(_storageKey, jsonEncode(jsonList));
- _historyLog.d('Saved ${state.items.length} items to storage');
- } catch (e) {
- _historyLog.e('Failed to save history: $e');
+
+ final jsonList = await _db.getAll();
+ final items = jsonList
+ .map((e) => DownloadHistoryItem.fromJson(e))
+ .toList();
+
+ state = state.copyWith(items: items);
+ _historyLog.i('Loaded ${items.length} items from SQLite database');
+ } catch (e, stack) {
+ _historyLog.e('Failed to load history from database: $e', e, stack);
}
}
Future reloadFromStorage() async {
- await _loadFromStorage();
+ await _loadFromDatabase();
}
void addToHistory(DownloadHistoryItem item) {
- final existingIndex = state.items.indexWhere((existing) {
- if (item.spotifyId != null &&
- item.spotifyId!.isNotEmpty &&
- existing.spotifyId == item.spotifyId) {
- return true;
- }
-
- if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
- existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
- final itemDeezerId = item.spotifyId!.substring(7);
- final existingDeezerId = existing.spotifyId!.substring(7);
- if (itemDeezerId == existingDeezerId) {
- return true;
- }
- }
-
- if (item.isrc != null &&
- item.isrc!.isNotEmpty &&
- existing.isrc == item.isrc) {
- return true;
- }
- return false;
- });
+ DownloadHistoryItem? existing;
+ if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
+ existing = state.getBySpotifyId(item.spotifyId!);
+ }
+ if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) {
+ existing = state.getByIsrc(item.isrc!);
+ }
- if (existingIndex >= 0) {
- final updatedItems = [...state.items];
- updatedItems[existingIndex] = item;
- updatedItems.removeAt(existingIndex);
+ if (existing != null) {
+ final updatedItems = state.items.where((i) => i.id != existing!.id).toList();
updatedItems.insert(0, item);
state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}');
@@ -277,31 +235,60 @@ class DownloadHistoryNotifier extends Notifier {
state = state.copyWith(items: [item, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}');
}
- _saveToStorage();
+
+ _db.upsert(item.toJson()).catchError((e) {
+ _historyLog.e('Failed to save to database: $e');
+ });
}
void removeFromHistory(String id) {
state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(),
);
- _saveToStorage();
+ _db.deleteById(id).catchError((e) {
+ _historyLog.e('Failed to delete from database: $e');
+ });
}
void removeBySpotifyId(String spotifyId) {
state = state.copyWith(
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
);
- _saveToStorage();
+ _db.deleteBySpotifyId(spotifyId).catchError((e) {
+ _historyLog.e('Failed to delete from database: $e');
+ });
_historyLog.d('Removed item with spotifyId: $spotifyId');
}
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
- return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
+ return state.getBySpotifyId(spotifyId);
+ }
+
+ /// O(1) lookup by ISRC
+ DownloadHistoryItem? getByIsrc(String isrc) {
+ return state.getByIsrc(isrc);
+ }
+
+ /// Async version with database lookup (for cases where in-memory might be stale)
+ Future getBySpotifyIdAsync(String spotifyId) async {
+ final inMemory = state.getBySpotifyId(spotifyId);
+ if (inMemory != null) return inMemory;
+
+ final json = await _db.getBySpotifyId(spotifyId);
+ if (json == null) return null;
+ return DownloadHistoryItem.fromJson(json);
}
void clearHistory() {
state = DownloadHistoryState();
- _saveToStorage();
+ _db.clearAll().catchError((e) {
+ _historyLog.e('Failed to clear database: $e');
+ });
+ }
+
+ /// Get database stats for debugging
+ Future getDatabaseCount() async {
+ return await _db.getCount();
}
}
@@ -488,10 +475,21 @@ class DownloadQueueNotifier extends Notifier {
final currentItems = state.items;
final itemsById = {};
final itemIndexById = {};
+ int queuedCount = 0;
+ int downloadingCount = 0;
+ DownloadItem? firstDownloading;
for (int i = 0; i < currentItems.length; i++) {
final item = currentItems[i];
itemsById[item.id] = item;
itemIndexById[item.id] = i;
+ if (item.status == DownloadStatus.downloading) {
+ downloadingCount++;
+ firstDownloading ??= item;
+ }
+ if (item.status == DownloadStatus.queued ||
+ item.status == DownloadStatus.downloading) {
+ queuedCount++;
+ }
}
final progressUpdates = {};
@@ -613,15 +611,12 @@ class DownloadQueueNotifier extends Notifier {
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
- final downloadingItems = state.items
- .where((i) => i.status == DownloadStatus.downloading)
- .toList();
- if (downloadingItems.isNotEmpty) {
- final trackName = downloadingItems.length == 1
- ? downloadingItems.first.track.name
- : '${downloadingItems.length} downloads';
- final artistName = downloadingItems.length == 1
- ? downloadingItems.first.track.artistName
+ if (downloadingCount > 0 && firstDownloading != null) {
+ final trackName = downloadingCount == 1
+ ? firstDownloading.track.name
+ : '$downloadingCount downloads';
+ final artistName = downloadingCount == 1
+ ? firstDownloading.track.artistName
: 'Downloading...';
int notifProgress = bytesReceived;
@@ -643,11 +638,11 @@ class DownloadQueueNotifier extends Notifier {
if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress(
- trackName: downloadingItems.first.track.name,
- artistName: downloadingItems.first.track.artistName,
+ trackName: firstDownloading.track.name,
+ artistName: firstDownloading.track.artistName,
progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1,
- queueCount: state.queuedCount,
+ queueCount: queuedCount,
).catchError((_) {});
}
}
@@ -725,14 +720,29 @@ class DownloadQueueNotifier extends Notifier {
if (separateSingles) {
final isSingle = track.isSingle;
+ final artistName = _sanitizeFolderName(albumArtist);
+ // New option: Singles folder inside Artist folder
+ if (albumFolderStructure == 'artist_album_singles') {
+ if (isSingle) {
+ final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
+ await _ensureDirExists(singlesPath, label: 'Artist Singles folder');
+ return singlesPath;
+ } else {
+ final albumName = _sanitizeFolderName(track.albumName);
+ final albumPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
+ await _ensureDirExists(albumPath, label: 'Artist Album folder');
+ return albumPath;
+ }
+ }
+
+ // Existing behavior: Separate Albums/ and Singles/ at root
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Singles folder');
return singlesPath;
} else {
final albumName = _sanitizeFolderName(track.albumName);
- final artistName = _sanitizeFolderName(albumArtist);
final year = _extractYear(track.releaseDate);
String albumPath;
@@ -790,7 +800,7 @@ class DownloadQueueNotifier extends Notifier {
String _sanitizeFolderName(String name) {
return name
.replaceAll(_invalidFolderChars, '_')
- .replaceAll(_trailingDotsRegex, '') // Remove trailing dots
+ .replaceAll(_trailingDotsRegex, '')
.trim();
}
@@ -1067,8 +1077,8 @@ class DownloadQueueNotifier extends Notifier {
/// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) {
- const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
- const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
+ const spotifySize300 = 'ab67616d00001e02';
+ const spotifySize640 = 'ab67616d0000b273';
const spotifySizeMax = 'ab67616d000082c1';
var result = coverUrl;
@@ -1182,10 +1192,13 @@ class DownloadQueueNotifier extends Notifier {
durationMs: durationMs,
);
- if (lrcContent.isNotEmpty) {
+ // Skip instrumental tracks (no lyrics to embed)
+ if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
+ } else if (lrcContent == '[instrumental:true]') {
+ _log.d('Track is instrumental, skipping lyrics embedding');
}
} catch (e) {
_log.w('Failed to fetch lyrics for embedding: $e');
@@ -1655,7 +1668,7 @@ class DownloadQueueNotifier extends Notifier {
final quality = item.qualityOverride ?? state.audioQuality;
- // Fetch extended metadata (genre, label) from Deezer if available
+// Fetch extended metadata (genre, label) from Deezer if available
String? genre;
String? label;
@@ -1667,6 +1680,19 @@ class DownloadQueueNotifier extends Notifier {
deezerTrackId = trackToDownload.availability!.deezerId;
}
+ if (deezerTrackId == null && trackToDownload.isrc != null && trackToDownload.isrc!.isNotEmpty) {
+ try {
+ _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}');
+ final deezerResult = await PlatformBridge.searchDeezerByISRC(trackToDownload.isrc!);
+ if (deezerResult['success'] == true && deezerResult['track_id'] != null) {
+ deezerTrackId = deezerResult['track_id'].toString();
+ _log.d('Found Deezer track ID via ISRC: $deezerTrackId');
+ }
+ } catch (e) {
+ _log.w('Failed to search Deezer by ISRC: $e');
+ }
+ }
+
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
try {
final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId);
@@ -1758,9 +1784,8 @@ class DownloadQueueNotifier extends Notifier {
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
- itemId: item.id, // Pass item ID for progress tracking
- durationMs:
- trackToDownload.duration, // Duration in ms for verification
+ itemId: item.id,
+ durationMs: trackToDownload.duration,
);
}
@@ -1800,7 +1825,7 @@ class DownloadQueueNotifier extends Notifier {
final actualBitDepth = result['actual_bit_depth'] as int?;
final actualSampleRate = result['actual_sample_rate'] as int?;
- String actualQuality = quality; // Default to requested quality
+ String actualQuality = quality;
if (actualBitDepth != null && actualBitDepth > 0) {
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart
new file mode 100644
index 00000000..b4e4e718
--- /dev/null
+++ b/lib/providers/explore_provider.dart
@@ -0,0 +1,265 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:spotiflac_android/services/platform_bridge.dart';
+import 'package:spotiflac_android/utils/logger.dart';
+import 'package:spotiflac_android/providers/extension_provider.dart';
+
+final _log = AppLogger('ExploreProvider');
+
+/// Represents an item in a Spotify home section
+class ExploreItem {
+ final String id;
+ final String uri;
+ final String type; // track, album, playlist, artist, station
+ final String name;
+ final String artists;
+ final String? description;
+ final String? coverUrl;
+ final String? providerId;
+ final String? albumId;
+ final String? albumName;
+ final int durationMs;
+
+ const ExploreItem({
+ required this.id,
+ required this.uri,
+ required this.type,
+ required this.name,
+ required this.artists,
+ this.description,
+ this.coverUrl,
+ this.providerId,
+ this.albumId,
+ this.albumName,
+ this.durationMs = 0,
+ });
+
+ factory ExploreItem.fromJson(Map json) {
+ return ExploreItem(
+ id: json['id'] as String? ?? '',
+ uri: json['uri'] as String? ?? '',
+ type: json['type'] as String? ?? 'track',
+ name: json['name'] as String? ?? '',
+ artists: json['artists'] as String? ?? '',
+ description: json['description'] as String?,
+ coverUrl: json['cover_url'] as String?,
+ providerId: json['provider_id'] as String?,
+ albumId: json['album_id'] as String?,
+ albumName: json['album_name'] as String?,
+ durationMs: json['duration_ms'] as int? ?? 0,
+ );
+ }
+}
+
+/// Represents a section in Spotify home feed
+class ExploreSection {
+ final String uri;
+ final String title;
+ final List items;
+ final bool isYTMusicQuickPicks;
+
+ const ExploreSection({
+ required this.uri,
+ required this.title,
+ required this.items,
+ this.isYTMusicQuickPicks = false,
+ });
+
+ factory ExploreSection.fromJson(Map json) {
+ final itemsList = json['items'] as List? ?? [];
+ final items = itemsList
+ .map((item) => ExploreItem.fromJson(item as Map))
+ .toList();
+ final isQuickPicks = _isYTMusicQuickPicksItems(items);
+ return ExploreSection(
+ uri: json['uri'] as String? ?? '',
+ title: json['title'] as String? ?? '',
+ items: items,
+ isYTMusicQuickPicks: isQuickPicks,
+ );
+ }
+}
+
+/// State for explore/home feed
+class ExploreState {
+ final bool isLoading;
+ final String? error;
+ final String? greeting;
+ final List sections;
+ final DateTime? lastFetched;
+
+ const ExploreState({
+ this.isLoading = false,
+ this.error,
+ this.greeting,
+ this.sections = const [],
+ this.lastFetched,
+ });
+
+ bool get hasContent => sections.isNotEmpty;
+
+ ExploreState copyWith({
+ bool? isLoading,
+ String? error,
+ String? greeting,
+ List? sections,
+ DateTime? lastFetched,
+ }) {
+ return ExploreState(
+ isLoading: isLoading ?? this.isLoading,
+ error: error,
+ greeting: greeting ?? this.greeting,
+ sections: sections ?? this.sections,
+ lastFetched: lastFetched ?? this.lastFetched,
+ );
+ }
+}
+
+/// Calculate greeting based on local device time
+String _getLocalGreeting() {
+ final hour = DateTime.now().hour;
+ if (hour >= 5 && hour < 12) {
+ return 'Good morning';
+ } else if (hour >= 12 && hour < 17) {
+ return 'Good afternoon';
+ } else if (hour >= 17 && hour < 21) {
+ return 'Good evening';
+ } else {
+ return 'Good night';
+ }
+}
+
+bool _isYTMusicQuickPicksItems(List items) {
+ if (items.isEmpty) return false;
+ if (items.first.providerId != 'ytmusic-spotiflac') return false;
+ for (final item in items) {
+ if (item.type != 'track') {
+ return false;
+ }
+ }
+ return true;
+}
+
+/// Provider for explore/home feed state
+class ExploreNotifier extends Notifier {
+ @override
+ ExploreState build() {
+ return const ExploreState();
+ }
+
+ /// Fetch home feed from spotify-web extension
+ Future fetchHomeFeed({bool forceRefresh = false}) async {
+ _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
+
+ // Don't refetch if we have data and it's less than 5 minutes old
+ if (!forceRefresh &&
+ state.hasContent &&
+ state.lastFetched != null &&
+ DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
+ _log.d('Using cached home feed');
+ return;
+ }
+
+ if (state.isLoading) {
+ _log.d('Home feed fetch already in progress');
+ return;
+ }
+
+ state = state.copyWith(isLoading: true, error: null);
+
+ try {
+ // Find any extension with homeFeed capability
+ final extState = ref.read(extensionProvider);
+ _log.d('Extensions count: ${extState.extensions.length}');
+
+ // Look for extensions with homeFeed capability (prefer spotify-web)
+ Extension? targetExt;
+ for (final extension in extState.extensions) {
+ if (!extension.enabled || !extension.hasHomeFeed) {
+ continue;
+ }
+ if (targetExt == null || extension.id == 'spotify-web') {
+ targetExt = extension;
+ if (extension.id == 'spotify-web') {
+ break;
+ }
+ }
+ }
+
+ if (targetExt == null) {
+ _log.w('No extension with homeFeed capability found');
+ state = state.copyWith(
+ isLoading: false,
+ error: 'No extension with home feed support enabled',
+ );
+ return;
+ }
+
+ _log.i('Fetching home feed from ${targetExt.id}...');
+ final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
+
+ if (result == null) {
+ state = state.copyWith(
+ isLoading: false,
+ error: 'Failed to fetch home feed',
+ );
+ return;
+ }
+
+ final success = result['success'] as bool? ?? false;
+ _log.d('getExtensionHomeFeed success=$success');
+ if (!success) {
+ final error = result['error'] as String? ?? 'Unknown error';
+ state = state.copyWith(
+ isLoading: false,
+ error: error,
+ );
+ return;
+ }
+
+ final greeting = result['greeting'] as String?;
+ final sectionsData = result['sections'] as List? ?? [];
+
+ final sections = sectionsData
+ .map((s) => ExploreSection.fromJson(s as Map))
+ .toList();
+
+ _log.i('Fetched ${sections.length} sections');
+
+ // Debug: log first section items
+ if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
+ final firstItem = sections.first.items.first;
+ _log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
+ }
+
+ // Always use local device time for greeting to avoid timezone issues
+ // Extension greeting may use wrong timezone (UTC or Spotify account timezone)
+ final localGreeting = _getLocalGreeting();
+ _log.d('Greeting from extension: $greeting, using local: $localGreeting');
+
+ state = ExploreState(
+ isLoading: false,
+ greeting: localGreeting,
+ sections: sections,
+ lastFetched: DateTime.now(),
+ );
+ } catch (e, stack) {
+ _log.e('Error fetching home feed: $e', e, stack);
+ state = state.copyWith(
+ isLoading: false,
+ error: e.toString(),
+ );
+ }
+ }
+
+ /// Clear cached data
+ void clear() {
+ state = const ExploreState();
+ }
+
+ /// Refresh home feed
+ Future refresh() => fetchHomeFeed(forceRefresh: true);
+}
+
+final exploreProvider = NotifierProvider(() {
+ return ExploreNotifier();
+});
diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart
index 6f74d25b..a4c3d25e 100644
--- a/lib/providers/extension_provider.dart
+++ b/lib/providers/extension_provider.dart
@@ -26,6 +26,7 @@ class Extension {
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
+ final Map capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
const Extension({
required this.id,
@@ -48,6 +49,7 @@ class Extension {
this.urlHandler,
this.trackMatching,
this.postProcessing,
+ this.capabilities = const {},
});
factory Extension.fromJson(Map json) {
@@ -84,6 +86,7 @@ class Extension {
postProcessing: json['post_processing'] != null
? PostProcessing.fromJson(json['post_processing'] as Map)
: null,
+ capabilities: (json['capabilities'] as Map?) ?? const {},
);
}
@@ -108,6 +111,7 @@ class Extension {
URLHandler? urlHandler,
TrackMatching? trackMatching,
PostProcessing? postProcessing,
+ Map? capabilities,
}) {
return Extension(
id: id ?? this.id,
@@ -130,6 +134,7 @@ class Extension {
urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching,
postProcessing: postProcessing ?? this.postProcessing,
+ capabilities: capabilities ?? this.capabilities,
);
}
@@ -137,6 +142,8 @@ class Extension {
bool get hasURLHandler => urlHandler?.enabled ?? false;
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
bool get hasPostProcessing => postProcessing?.enabled ?? false;
+ bool get hasHomeFeed => capabilities['homeFeed'] == true;
+ bool get hasBrowseCategories => capabilities['browseCategories'] == true;
}
class SearchBehavior {
diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart
index ab0b1466..0070cc95 100644
--- a/lib/providers/recent_access_provider.dart
+++ b/lib/providers/recent_access_provider.dart
@@ -100,6 +100,8 @@ class RecentAccessState {
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier {
+ final Future _prefs = SharedPreferences.getInstance();
+
@override
RecentAccessState build() {
_loadHistory();
@@ -107,7 +109,7 @@ class RecentAccessNotifier extends Notifier {
}
Future _loadHistory() async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = await _prefs;
final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
@@ -132,13 +134,13 @@ class RecentAccessNotifier extends Notifier {
}
Future _saveHistory() async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = await _prefs;
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json);
}
Future _saveHiddenDownloads() async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = await _prefs;
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
}
diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart
index 1e7830f4..b7c9687f 100644
--- a/lib/providers/settings_provider.dart
+++ b/lib/providers/settings_provider.dart
@@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
class SettingsNotifier extends Notifier {
+ final Future _prefs = SharedPreferences.getInstance();
+
@override
AppSettings build() {
_loadSettings();
@@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier {
}
Future _loadSettings() async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
@@ -46,7 +48,7 @@ class SettingsNotifier extends Notifier {
}
Future _saveSettings() async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = await _prefs;
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
}
diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart
index 6a314cab..3fdd824f 100644
--- a/lib/providers/store_provider.dart
+++ b/lib/providers/store_provider.dart
@@ -5,12 +5,13 @@ import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider');
+final RegExp _leadingVersionPrefix = RegExp(r'^v');
/// Compare two semantic version strings
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
int compareVersions(String v1, String v2) {
- final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
- final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
+ final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
+ final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart
index f1a3e728..7c38b33c 100644
--- a/lib/providers/theme_provider.dart
+++ b/lib/providers/theme_provider.dart
@@ -10,6 +10,8 @@ final themeProvider = NotifierProvider(() {
/// Notifier for managing theme settings with persistence
class ThemeNotifier extends Notifier {
+ final Future _prefs = SharedPreferences.getInstance();
+
@override
ThemeSettings build() {
// Load settings asynchronously on first access
@@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier {
/// Load theme settings from SharedPreferences
Future _loadFromStorage() async {
try {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = await _prefs;
final modeString = prefs.getString(kThemeModeKey);
final useDynamic = prefs.getBool(kUseDynamicColorKey);
final seedColor = prefs.getInt(kSeedColorKey);
@@ -40,7 +42,7 @@ class ThemeNotifier extends Notifier {
/// Save current settings to SharedPreferences
Future _saveToStorage() async {
try {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = await _prefs;
await prefs.setString(kThemeModeKey, state.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
await prefs.setInt(kSeedColorKey, state.seedColorValue);
diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart
index 86db03de..f2d176d7 100644
--- a/lib/screens/album_screen.dart
+++ b/lib/screens/album_screen.dart
@@ -2,7 +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/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -12,6 +12,8 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
+import 'package:spotiflac_android/screens/artist_screen.dart';
+import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
class _AlbumCache {
static final Map _cache = {};
@@ -43,6 +45,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String? coverUrl;
final List