Compare commits

...

87 Commits

Author SHA1 Message Date
zarzet 0ccda8db58 fix: locale format and translation updates 2026-01-18 03:27:43 +07:00
zarzet 6d7b89b881 v3.1.1: Lyrics caching, duration matching, Deezer cover upgrade, live extension search, Russian language, fix race conditions and scroll exceptions 2026-01-18 03:15:20 +07:00
Zarz Eleutherius 47777b4343 Merge pull request #65 from zarzet/l10n_dev
New Crowdin updates
2026-01-18 01:46:48 +07:00
Zarz Eleutherius 2eb1d2a65d New translations app_en.arb (Russian) 2026-01-18 01:45:39 +07:00
Zarz Eleutherius ce057c6473 New translations app_en.arb (Japanese) 2026-01-18 01:45:36 +07:00
Zarz Eleutherius 46cfe8b632 Merge pull request #58 from zarzet/l10n_dev
New Crowdin updates
2026-01-17 22:01:48 +07:00
zarzet 2e5eff6e3d chore: add cursor files to gitignore 2026-01-17 10:10:56 +07:00
zarzet dd506efeb6 chore: remove .cursorignore from tracking 2026-01-17 10:10:20 +07:00
zarzet 8d92d22fda refactor: more code cleanup 2026-01-17 10:04:21 +07:00
zarzet b99764b1ad refactor: cleanup unused code and imports 2026-01-17 09:50:00 +07:00
zarzet 621582cf11 refactor: additional code cleanup 2026-01-17 09:36:05 +07:00
zarzet b96233f90b refactor: code cleanup and improvements 2026-01-17 09:07:29 +07:00
zarzet be9444c76b fix: revert material_color_utilities to ^0.11.1 (pinned by Flutter SDK) 2026-01-17 05:41:33 +07:00
Zarz Eleutherius 65e21a421d New translations app_en.arb (Hindi) 2026-01-17 05:23:55 +07:00
Zarz Eleutherius 87b33dda7e New translations app_en.arb (Indonesian) 2026-01-17 05:23:54 +07:00
Zarz Eleutherius 2f097c8f6c New translations app_en.arb (Chinese Traditional) 2026-01-17 05:23:53 +07:00
Zarz Eleutherius 8cbdea1417 New translations app_en.arb (Chinese Simplified) 2026-01-17 05:23:52 +07:00
Zarz Eleutherius 48bdd154f6 New translations app_en.arb (Russian) 2026-01-17 05:23:51 +07:00
Zarz Eleutherius ae0e157c34 New translations app_en.arb (Portuguese) 2026-01-17 05:23:50 +07:00
Zarz Eleutherius 53fcdd9a47 New translations app_en.arb (Dutch) 2026-01-17 05:23:49 +07:00
Zarz Eleutherius 3d6be3bf92 New translations app_en.arb (Korean) 2026-01-17 05:23:48 +07:00
Zarz Eleutherius 2d7fba3f52 New translations app_en.arb (Japanese) 2026-01-17 05:23:47 +07:00
Zarz Eleutherius e02d8ff2cd New translations app_en.arb (German) 2026-01-17 05:23:46 +07:00
Zarz Eleutherius f8cee25958 New translations app_en.arb (Spanish) 2026-01-17 05:23:45 +07:00
Zarz Eleutherius 99c133aae1 New translations app_en.arb (French) 2026-01-17 05:23:45 +07:00
zarzet cedb32904e fix(ios): add localization support for iOS build
- Add flutter_localizations and intl to pubspec_ios.yaml

- Add generate: true flag for l10n code generation

- Add CFBundleLocalizations to Info.plist with all supported languages

- Update http to ^1.6.0 and material_color_utilities to ^0.13.0

- Update file_picker to ^10.3.8
2026-01-17 05:20:04 +07:00
zarzet e73f932083 fix: update Crowdin config for Chinese locales and add missing l10n keys
- Change crowdin.yml to use %locale_with_underscore% for proper zh_CN/zh_TW handling
- Add sectionLanguage, appearanceLanguage, appearanceLanguageSubtitle to app_en.arb
- Add app_zh_CN.arb for Simplified Chinese (Crowdin target)
- Update .gitignore to exclude log files and tool/ folder
- Regenerate localization dart files
2026-01-17 05:02:57 +07:00
zarzet 4645d3ac8b fix: correct @@locale values to match filenames (es-ES→es, pt-PT→pt, zh-TW→zh) 2026-01-17 04:51:21 +07:00
Zarz Eleutherius 1cdf8b7f23 Merge pull request #57 from zarzet/l10n_dev
New Crowdin updates
2026-01-17 04:34:21 +07:00
zarzet 1e18f53e6a Merge dev into l10n_dev - resolve conflicts by keeping dev version 2026-01-17 04:33:34 +07:00
zarzet fc8cfb05d0 feat: add recent access history, artist screen redesign, and extension improvements
Recent Access History:
- Quick access to recently visited artists, albums, playlists, and tracks
- Tap search bar to show recent access list
- Stays visible after keyboard dismiss, exit with back button
- Persists across app restarts (SharedPreferences)
- X button to remove items, Clear All button for all

Artist Screen Redesign:
- Full-width header image with gradient overlay
- Monthly listeners display with compact notation
- Popular section with top 5 tracks and download status
- Extension artists skip Spotify/Deezer fetch (no rate limit errors)

Go Backend:
- GetArtistWithExtensionJSON now returns top_tracks, header_image, listeners

Bug Fixes:
- Search bar unfocus when tapping outside
- Keyboard not appearing on Settings navigation return
- Recent access artist navigation uses correct screen for extensions
- Extension artist screen correctly parses and forwards top tracks

Localization:
- Added recentPlaylistInfo, errorGeneric strings
- Multi-language support via Crowdin

Extensions:
- YT Music: v1.5.0 (top_tracks in getArtist)
- Spotify Web: v1.6.0
2026-01-17 04:29:39 +07:00
Zarz Eleutherius fc0c0571fe New translations app_en.arb (Hindi) 2026-01-16 07:24:21 +07:00
Zarz Eleutherius e6ca29e199 New translations app_en.arb (Indonesian) 2026-01-16 07:24:20 +07:00
Zarz Eleutherius 7413a8a698 New translations app_en.arb (Chinese Traditional) 2026-01-16 07:24:19 +07:00
Zarz Eleutherius 205032e094 New translations app_en.arb (Chinese Simplified) 2026-01-16 07:24:18 +07:00
Zarz Eleutherius 9c6f438e22 New translations app_en.arb (Russian) 2026-01-16 07:24:17 +07:00
Zarz Eleutherius 4f2587554a New translations app_en.arb (Portuguese) 2026-01-16 07:24:16 +07:00
Zarz Eleutherius 369fdd84bf New translations app_en.arb (Dutch) 2026-01-16 07:24:15 +07:00
Zarz Eleutherius 5c3b668e92 New translations app_en.arb (Korean) 2026-01-16 07:24:14 +07:00
Zarz Eleutherius 141db45051 New translations app_en.arb (Japanese) 2026-01-16 07:24:13 +07:00
Zarz Eleutherius 8f9bc8f058 New translations app_en.arb (German) 2026-01-16 07:24:11 +07:00
Zarz Eleutherius be372604fe New translations app_en.arb (Spanish) 2026-01-16 07:24:10 +07:00
Zarz Eleutherius 6c25fc6a8d New translations app_en.arb (French) 2026-01-16 07:24:09 +07:00
Zarz Eleutherius 2eef021587 Update source file app_en.arb 2026-01-16 07:24:03 +07:00
zarzet 9eac6e6e56 docs: add Crowdin translation badge to README 2026-01-16 07:07:46 +07:00
zarzet e5c310f455 fix: update crowdin.yml to use two_letters_code for locale format
- Change from %locale% (id-ID) to %two_letters_code% (id)
- Matches Flutter l10n expected filename format
2026-01-16 06:39:16 +07:00
zarzet d8f73dfa56 feat: add support for 13 languages with improved language selector
- Rename Crowdin ARB files from locale-REGION to locale format
- Fix @@locale values to match filenames
- Update language selector to bottom sheet picker (supports 13 languages)
- Supported: English, Indonesian, German, Spanish, French, Hindi,
  Japanese, Korean, Dutch, Portuguese, Russian, Chinese (Simplified/Traditional)
- Remove duplicate app_id-ID.arb (keep app_id.arb)
2026-01-16 06:38:52 +07:00
zarzet f128d0caf0 Merge branch 'dev' of https://github.com/zarzet/SpotiFLAC-Mobile into dev 2026-01-16 06:35:44 +07:00
zarzet aa499ceba2 refactor: use consistent ViewModeChip for language selector
- Remove duplicate _LanguageChip widget
- Reuse _ViewModeChip for Material Design 3 consistency
- Same font size (12), padding, and styling as other selectors
2026-01-16 06:28:26 +07:00
zarzet 01306afc2d feat: add language selector in Appearance settings
- Add locale field to AppSettings model with 'system' default
- Add Language section in Appearance settings page
- Support System Default, English, and Indonesian options
- App language changes take effect immediately
- Add localization strings for language selector
2026-01-16 06:25:24 +07:00
Zarz Eleutherius 9a3cd0273b Merge pull request #56 from zarzet/l10n_dev
New Crowdin updates
2026-01-16 06:25:02 +07:00
Zarz Eleutherius ac25683f33 New translations app_en.arb (Hindi) 2026-01-16 06:22:51 +07:00
Zarz Eleutherius 624b2112d8 New translations app_en.arb (Indonesian) 2026-01-16 06:22:50 +07:00
Zarz Eleutherius 8bd34dc87e New translations app_en.arb (Chinese Traditional) 2026-01-16 06:22:49 +07:00
Zarz Eleutherius 948779bcfc New translations app_en.arb (Chinese Simplified) 2026-01-16 06:22:48 +07:00
Zarz Eleutherius a74b3a19f7 New translations app_en.arb (Russian) 2026-01-16 06:22:47 +07:00
Zarz Eleutherius 931d9fbf61 New translations app_en.arb (Portuguese) 2026-01-16 06:22:46 +07:00
Zarz Eleutherius a8c76004db New translations app_en.arb (Dutch) 2026-01-16 06:22:45 +07:00
Zarz Eleutherius 0df4596f79 New translations app_en.arb (Korean) 2026-01-16 06:22:44 +07:00
Zarz Eleutherius cf549df049 New translations app_en.arb (Japanese) 2026-01-16 06:22:43 +07:00
Zarz Eleutherius bd3783154b New translations app_en.arb (German) 2026-01-16 06:22:42 +07:00
Zarz Eleutherius 6919408905 New translations app_en.arb (Spanish) 2026-01-16 06:22:41 +07:00
Zarz Eleutherius f4c08a5981 New translations app_en.arb (French) 2026-01-16 06:22:39 +07:00
zarzet 7fff55da96 chore: add crowdin.yml for translation sync 2026-01-16 06:16:28 +07:00
zarzet 3c4dbd1a80 docs: add @key metadata descriptions for Crowdin translators
- Add description field to all 400+ localization strings
- Mark brand names and technical terms as DO NOT TRANSLATE
- Add placeholder descriptions for parameterized strings
- Helps translators understand context for each string
2026-01-16 05:58:36 +07:00
zarzet f26af38c1e feat: add multilanguage support (i18n) for English and Indonesian
- Add flutter_localizations and intl dependencies
- Create l10n.yaml configuration and ARB files (app_en.arb, app_id.arb)
- Add L10n extension for easy context.l10n access
- Localize all active screens:
  - setup_screen, track_metadata_screen, log_screen
  - download_settings_page, options_settings_page, appearance_settings_page
  - extensions_page, extension_detail_page, extension_details_screen
  - about_page, provider_priority_page, metadata_provider_priority_page
  - home_tab, queue_tab, store_tab, main_shell
  - album_screen, artist_screen, playlist_screen
  - downloaded_album_screen, queue_screen
- Localize widgets: update_dialog, download_service_picker
- Technical terms (FLAC, API, Spotify, Tidal, Qobuz, etc.) are NOT translated
- ~900+ localized strings in English, ~660+ in Indonesian
2026-01-16 05:50:11 +07:00
zarzet 7c6705c75c merge: main into dev - resolved conflicts for mime utils, version, gitignore 2026-01-16 03:47:45 +07:00
zarzet b193bc0b8f feat: download cancellation, duplicate detection, progress tracking improvements 2026-01-16 03:46:31 +07:00
zarzet 1a90887465 release: v3.1.0 - fix Separate Singles, extension metadata, YTMusic parsing
Fixes:
- Fix Separate Singles not working (#54) - albumType not extracted from Deezer API
- Fix extension artist/album metadata missing provider IDs and cover URLs
- Fix YTMusic extension not extracting album name and duration from search
- Fix extension collection screens setState after dispose
- Fix search source chips referencing removed badge props

Changes:
- Deezer convertTrack now includes album_type from record_type
- Track creation preserves albumType and source throughout download flow
- Go exports include provider_id in album/artist responses
- Version bump to 3.1.0+59
2026-01-15 18:54:01 +07:00
zarzet 82440affac feat: add home tab enhancements, download queue improvements, and platform bridge updates 2026-01-15 04:31:33 +07:00
zarzet 6d2f75c5dc docs: update screenshots and VirusTotal link for v3.0.0 2026-01-14 21:42:16 +07:00
zarzet 18bc079632 Merge dev into main: v3.0.0 stable release 2026-01-14 18:08:30 +07:00
zarzet 4091a9c499 release: v3.0.0 stable with Extension System 2026-01-14 01:57:30 +07:00
zarzet 9346f2d149 fix: bottom overflow in Folder Organization dialog 2026-01-14 01:00:52 +07:00
zarzet 8ab52959e8 refactor: simplify parallel download result handling in tidal/qobuz 2026-01-14 00:57:04 +07:00
zarzet bad95e99c8 fix: remove unused getDownloadURLSequential from tidal.go
Replaced by parallel version for faster API responses
2026-01-14 00:39:59 +07:00
zarzet dbd7fd70be fix: remove unused function and fix bit shifting warnings
- Remove unused getQobuzDownloadURLSequential (replaced by parallel version)
- Fix bit shifting on byte values in metadata.go (cast to uint32 before shift)
2026-01-14 00:38:46 +07:00
zarzet 125d070cfe fix: remove duplicate --- separator in release notes
Extract changelog now strips trailing --- from CHANGELOG.md sections
2026-01-13 23:51:59 +07:00
zarzet 15acf181d1 fix: back gesture freeze on Android 13+ and add album folder structure setting
- Add PopScope with canPop:true to all settings pages for predictive back gesture support
- Change settings navigation to use PageRouteBuilder instead of MaterialPageRoute
- Add album folder structure setting (artist_album vs album_only)
- Fix extension search result parsing to handle both array and object formats
- Update CHANGELOG

Fixes back gesture freeze issue on OnePlus and other Android 13+ devices with gesture navigation
2026-01-13 23:48:02 +07:00
zarzet e049f9b868 fix: improve artist matching for multi-artist tracks and add cover logging 2026-01-13 20:55:46 +07:00
zarzet 6a886c5276 fix: handle Japanese artist name order in Tidal/Qobuz matching 2026-01-13 20:31:05 +07:00
zarzet 1ec190bfe7 fix: multiple bugfixes for v3.0.0-beta.2 2026-01-13 20:12:35 +07:00
zarzet 7ca032b3f5 fix: remove unnecessary PopScope to prevent back gesture freeze
Removes PopScope wrapper from settings pages that don't need it.
PopScope with canPop: true was causing race condition with Android
gesture navigation, freezing the app.
2026-01-13 18:18:41 +07:00
zarzet 13b917d1a0 fix: preserve directory structure when extracting extension packages 2026-01-13 17:50:12 +07:00
zarzet 961072e2ac security: use per-installation random salt for credential encryption 2026-01-13 17:44:14 +07:00
zarzet 8a7815268b security: improve extension sandbox security
- Add file permission requirement for extensions

- Bump version to 3.0.0-beta.1
2026-01-13 17:41:24 +07:00
zarzet 00753ffe86 chore: increase log buffer size from 500 to 1000 entries 2026-01-12 23:17:30 +07:00
122 changed files with 73223 additions and 3241 deletions
+2
View File
@@ -345,6 +345,8 @@ jobs:
CHANGELOG="See CHANGELOG.md for details."
else
echo "Found changelog content"
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
fi
# Save to file for multiline support
+19
View File
@@ -6,6 +6,8 @@ Thumbs.db
.idea/
.vscode/
*.iml
.cursorignore
.cursorrules
# Kiro specs (development only)
.kiro/
@@ -53,3 +55,20 @@ ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
# Extension folder
extension/
# Agent instructions
AGENTS.md
# Temp/misc
nul
# Log files
*.log
hs_err_*.log
flutter_*.log
# Development tools
tool/
+466 -17
View File
@@ -1,10 +1,466 @@
# Changelog
## [3.0.0-alpha.4] - Upcoming
## [3.1.1] - 2026-01-17
### Added
- **Lyrics Caching**: Lyrics are now cached for 24 hours to reduce API calls and improve performance
- Thread-safe cache with automatic expiration
- Cache key based on artist, track, and duration
- Log indicator shows "(cached)" when lyrics are served from cache
- **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching
- Compares track duration with lrclib.net results
- 10-second tolerance to handle version differences (radio edit, remaster, etc.)
- Prioritizes synced lyrics over plain text when duration matches
- Falls back gracefully if no duration match found
- **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality
- Detects Deezer CDN URLs (`cdn-images.dzcdn.net`)
- Upgrades cover resolution to 1800x1800 (max available)
- Works alongside existing cover upgrade
- **Live Search for Extensions**: Search-as-you-type functionality for extension search
- 800ms debounce delay to prevent excessive API calls
- Minimum 3 characters required before searching
- Concurrency control to prevent race conditions in extension runtime
- Queues pending searches if a search is already in progress
- **Russian Language Support**: Added Russian (Русский) translation - 99% complete
- Translated via Crowdin community contributions
- Covers all UI elements, settings, and error messages
### Fixed
- **ISRC Index Race Condition**: Fixed repeated index rebuilding during parallel downloads
- Added per-directory build lock using `sync.Map` and `sync.Mutex`
- Double-check locking pattern ensures index is built only once
- Significantly improves performance during CSV import with many tracks
- **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView
- Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion
- Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors
- Issue was especially noticeable during rapid queue updates (CSV import)
- **CSV Import**: Fixed CSV export not being parsed correctly
- Added support for `Artist Name(s)` header (with parentheses)
- Added support for `Track URI` header for track IDs
- Added `artists` and `track_id` as alternative header names
- Now correctly parses "Liked Songs" and playlist exports
---
## [3.1.0] - 2026-01-16
### Added
- **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.
### 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
### 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}"
---
## [3.0.0] - 2026-01-14
### Extension System (Major Feature)
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
- New "Store" tab in bottom navigation
- Browse extensions by category (Metadata, Download, Utility, Lyrics, Integration)
- Search extensions by name, description, or tags
@@ -13,13 +469,15 @@
- Extensions hosted at github.com/zarzet/SpotiFLAC-Extension
- **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
- 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
@@ -36,13 +494,13 @@
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
- Added Custom URL Handler section with examples
- Added `handleURL` function documentation
- Added `handleUrl` function documentation
- Added URL pattern examples for YouTube, SoundCloud, Bandcamp
- Added `utils.hmacSHA1` documentation with TOTP example
### Extensions
- **Spotify Web Extension** (example): New extension for Spotify metadata via web API
- **Web Extension** (example): New extension for metadata via web API
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
- Search, album, playlist, track, and artist fetching
- Available in Extension Store (3.0.0-alpha.4)
@@ -54,7 +512,7 @@
### Added
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
- Based on `album_type` from Spotify/Deezer metadata
- Based on `album_type` from metadata
- Toggle in Settings > Download > Separate Singles Folder
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
- **Browser-like Polyfills**: New global APIs for easier library porting
@@ -74,7 +532,7 @@
### Fixed
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
- Detects existing entries by track ID, Deezer ID, or ISRC
- Replaces existing entry and moves to top of list
- Auto-deduplicates existing history on app load
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
@@ -97,7 +555,7 @@
- **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.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
@@ -139,6 +597,7 @@
## [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
@@ -217,16 +676,6 @@
- **Android Changes**:
- `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods
### Documentation
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
- Added thumbnail ratio customization section
- Added extension upgrade documentation
- Added settings fields table with `secret` field
- Added new troubleshooting entries
- Updated table of contents
- Updated changelog
---
## [2.2.8] - 2026-01-12
+36 -37
View File
@@ -1,11 +1,12 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center">
<img src="icon.png" width="128" />
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
@@ -23,62 +24,60 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
## Metadata Source
## Search Source
SpotiFLAC supports two metadata sources for searching tracks:
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Pros | Cons |
|--------|------|------|
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Extensions** | Install additional search providers from the Store |
### Using Spotify
To use Spotify as your search source without hitting rate limits:
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
2. Create an app to get your Client ID and Client Secret
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
4. Enter your Client ID and Secret
5. Change **Search Source** to Spotify
## Extensions
## Extensions (Alpha)
> **Alpha Feature**: Extensions are now available in alpha. Some features may be unstable or change in future releases.
SpotiFLAC supports extensions to add custom metadata and download providers. Extensions are written in JavaScript and run in a secure sandbox.
### Features
- **Metadata Providers**: Add new sources for track/album/artist search
- **Download Providers**: Add new sources for audio downloads
- **Custom Settings**: Extensions can have user-configurable settings
- **Provider Priority**: Set the order in which providers are tried
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
### Installing Extensions
1. Download a `.spotiflac-ext` file
2. Go to **Settings > Extensions**
3. Tap **Install Extension** and select the file
1. Go to **Store** tab in the app
2. Browse and install extensions with one tap
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
4. Configure extension settings if needed
5. Set provider priority in **Settings > Extensions > Provider Priority**
### Developing Extensions
Want to create your own extension? Check out the [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md) for complete documentation.
### Example Extensions
Sample extensions are available in the [docs/extensions_example](docs/extensions_example) folder:
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## FAQ
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
**Q: Can I download playlists?**
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
**Q: 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).
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet)
## Disclaimer
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
The application is purely a user interface that facilitates communication between your device and existing third-party services.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
@@ -117,6 +117,13 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"cancelDownload" -> {
val itemId = call.argument<String>("item_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.cancelDownload(itemId)
}
result.success(null)
}
"setDownloadDirectory" -> {
val path = call.argument<String>("path") ?: ""
withContext(Dispatchers.IO) {
@@ -151,8 +158,9 @@ class MainActivity: FlutterActivity() {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
Gobackend.fetchLyrics(spotifyId, trackName, artistName)
Gobackend.fetchLyrics(spotifyId, trackName, artistName, durationMs)
}
result.success(response)
}
@@ -161,8 +169,9 @@ class MainActivity: FlutterActivity() {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val filePath = call.argument<String>("file_path") ?: ""
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
val response = withContext(Dispatchers.IO) {
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs)
}
result.success(response)
}
@@ -572,6 +581,30 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"getAlbumWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val albumId = call.argument<String>("album_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
}
result.success(response)
}
"getPlaylistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val playlistId = call.argument<String>("playlist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
}
result.success(response)
}
"getArtistWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val artistId = call.argument<String>("artist_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
}
result.success(response)
}
// Extension Post-Processing API
"runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: ""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+19
View File
@@ -0,0 +1,19 @@
files:
- source: /lib/l10n/arb/app_en.arb
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Short codes for single-variant languages
de: de
es: es
fr: fr
hi: hi
id: id
ja: ja
ko: ko
nl: nl
pt: pt
ru: ru
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
+22 -38
View File
@@ -2,8 +2,10 @@ package gobackend
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -25,10 +27,9 @@ type AmazonDownloader struct {
}
var (
// Global Amazon downloader instance for connection reuse
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
amazonRateLimitMu sync.Mutex
)
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
@@ -53,17 +54,14 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
@@ -78,13 +76,10 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
expectedASCII := amazonIsASCIIString(expectedArtist)
foundASCII := amazonIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
@@ -125,7 +120,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
now := time.Now()
// Reset counter every minute
if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0
a.apiCallResetTime = now
@@ -153,7 +147,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
}
}
// Update tracking
a.lastAPICallTime = time.Now()
a.apiCallCount++
}
@@ -179,8 +172,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
for _, region := range a.regions {
GoLog("[Amazon] Trying region: %s...\n", region)
// Build base URL for DoubleDouble service
// Decode base64 service URL (same as PC)
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
@@ -299,7 +290,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
if status.Status == "done" {
fmt.Println("\n[Amazon] Download ready!")
// Build download URL
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
@@ -346,13 +336,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
@@ -361,6 +359,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
resp, err := a.client.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
@@ -370,7 +371,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
}
expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -380,16 +380,13 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return err
}
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body)
}
@@ -397,9 +394,11 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
@@ -440,24 +439,19 @@ type AmazonDownloadResult struct {
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Get Amazon URL from SongLink
songlink := NewSongLinkClient()
var availability *TrackAvailability
var err error
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
if strings.HasPrefix(req.SpotifyID, "deezer:") {
// Extract Deezer ID and use Deezer-based lookup
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
// Use Spotify ID
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
@@ -471,7 +465,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
// Create output directory if needed
if req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
@@ -490,10 +483,8 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
}
// Log match found
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
// Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -505,7 +496,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
@@ -522,19 +512,21 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return AmazonDownloadResult{}, ErrDownloadCancelled
}
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
@@ -545,14 +537,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
}
// Read existing metadata from downloaded file BEFORE embedding
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
if metaErr == nil && existingMeta != nil {
// Use file metadata if it has valid track/disc numbers and request doesn't have them
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
@@ -602,8 +591,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
// Read actual quality from the downloaded FLAC file
// Amazon API doesn't provide quality info, but we can read it from the file itself
quality, err := GetAudioQuality(outputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
@@ -611,8 +598,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
// Read metadata from file AFTER embedding to get accurate values
// This ensures we return what's actually in the file
finalMeta, metaReadErr := ReadMetadata(outputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
@@ -620,7 +605,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
// Use date from file if available
req.ReleaseDate = finalMeta.Date
}
}
+78
View File
@@ -0,0 +1,78 @@
package gobackend
import (
"context"
"errors"
"sync"
)
// ErrDownloadCancelled is returned when a download is cancelled by the user.
var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct {
cancel context.CancelFunc
canceled bool
}
var (
cancelMu sync.Mutex
cancelMap = make(map[string]*cancelEntry)
)
func initDownloadCancel(itemID string) context.Context {
if itemID == "" {
return context.Background()
}
cancelMu.Lock()
defer cancelMu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
cancel: cancel,
canceled: false,
}
return ctx
}
func cancelDownload(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
if ok {
entry.canceled = true
if entry.cancel != nil {
entry.cancel()
}
} else {
cancelMap[itemID] = &cancelEntry{canceled: true}
}
cancelMu.Unlock()
RemoveItemProgress(itemID)
}
func isDownloadCancelled(itemID string) bool {
if itemID == "" {
return false
}
cancelMu.Lock()
entry, ok := cancelMap[itemID]
canceled := ok && entry.canceled
cancelMu.Unlock()
return canceled
}
func clearDownloadCancel(itemID string) {
if itemID == "" {
return
}
cancelMu.Lock()
delete(cancelMap, itemID)
cancelMu.Unlock()
}
+70 -32
View File
@@ -4,15 +4,29 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
// Spotify image size codes (same as PC version)
const (
spotifySize640 = "ab67616d0000b273" // 640x640
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
)
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
// Same logic as PC version for consistency
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
}
return imageURL
}
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
// This avoids file permission issues on Android
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
@@ -20,20 +34,28 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("no cover URL provided")
}
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
GoLog("[Cover] Original URL: %s", coverURL)
downloadURL := convertSmallToMedium(coverURL)
if downloadURL != coverURL {
GoLog("[Cover] Upgraded 300x300 → 640x640")
}
// Upgrade to max quality if requested
downloadURL := coverURL
if maxQuality {
downloadURL = upgradeToMaxQuality(coverURL)
if downloadURL != coverURL {
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
}
}
GoLog("[Cover] Final URL: %s", downloadURL)
client := NewHTTPClientWithTimeout(DefaultTimeout)
// Create request with User-Agent (required by Spotify CDN)
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -54,48 +76,64 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err)
}
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
sizeKB := len(data) / 1024
var resolution string
if sizeKB > 200 {
resolution = "~2000x2000 (hi-res)"
} else if sizeKB > 50 {
resolution = "~640x640"
} else {
resolution = "~300x300"
}
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
return data, nil
}
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
// Uses same logic as PC version - replaces 640x640 size code with max resolution
// upgradeToMaxQuality upgrades cover URL to maximum quality
// Supports both Spotify and Deezer CDNs
func upgradeToMaxQuality(coverURL string) string {
// Spotify image URLs can be upgraded by changing the size parameter
// Format: https://i.scdn.co/image/ab67616d0000b273...
// ab67616d0000b273 = 640x640
// ab67616d000082c1 = Max resolution (~2000x2000)
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
// Try max resolution first
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
// Verify max resolution URL is available
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("HEAD", maxURL, nil)
if err == nil {
resp, err := DoRequestWithUserAgent(client, req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return maxURL
}
}
}
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
return coverURL
}
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
// Available sizes: 56, 250, 500, 1000, 1400, 1800
func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return coverURL
}
// Replace any size pattern with 1800x1800
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800")
}
return upgraded
}
// GetCoverFromSpotify gets cover URL from Spotify metadata
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
}
// Always upgrade small to medium first
result := convertSmallToMedium(imageURL)
if maxQuality {
return upgradeToMaxQuality(imageURL)
result = upgradeToMaxQuality(result)
}
return imageURL
return result
}
+2 -10
View File
@@ -22,8 +22,7 @@ const (
deezerCacheTTL = 10 * time.Minute
// Parallel ISRC fetching settings
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
deezerMaxParallelISRC = 10
)
// DeezerClient handles Deezer API interactions (no auth required)
@@ -36,7 +35,6 @@ type DeezerClient struct {
cacheMu sync.RWMutex
}
// Singleton instance
var (
deezerClient *DeezerClient
deezerClientOnce sync.Once
@@ -89,11 +87,9 @@ type deezerAlbumSimple struct {
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // Sometimes at album level
RecordType string `json:"record_type"` // album, single, ep, compile
}
// ... (skip other structs as they are fine/unchanged) ...
// ... (in convertTrack) ...
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := track.Artist.Name
if len(track.Contributors) > 0 {
@@ -115,7 +111,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
albumImage = track.Album.Cover
}
// Try to find release date
releaseDate := track.ReleaseDate
if releaseDate == "" {
releaseDate = track.Album.ReleaseDate
@@ -543,7 +538,6 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
return &result, nil
}
// Check if we got a valid response (ID > 0)
if track.ID == 0 {
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
}
@@ -566,7 +560,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
result := make(map[string]string)
var resultMu sync.Mutex
// First, check cache for existing ISRCs
var tracksToFetch []deezerTrack
c.cacheMu.RLock()
for _, track := range tracks {
@@ -624,7 +617,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
// GetTrackISRC fetches ISRC for a single track (with caching)
// Use this when you need ISRC for download
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
// Check cache first
c.cacheMu.RLock()
if isrc, ok := c.isrcCache[trackID]; ok {
c.cacheMu.RUnlock()
+44 -15
View File
@@ -18,30 +18,45 @@ type ISRCIndex struct {
mu sync.RWMutex
}
// Global ISRC index cache (per output directory)
var (
isrcIndexCache = make(map[string]*ISRCIndex)
isrcIndexCacheMu sync.RWMutex
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
isrcIndexTTL = 5 * time.Minute
)
// GetISRCIndex returns or builds an ISRC index for the given directory
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
// Return cached index if still valid
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
// Build new index
// Slow path: need to build index
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// Double-check cache after acquiring lock (another goroutine may have built it)
isrcIndexCacheMu.RLock()
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
return buildISRCIndex(outputDir)
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
// Same implementation as PC version for consistency
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
@@ -56,7 +71,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
startTime := time.Now()
fileCount := 0
// Walk directory - only check .flac files
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
@@ -67,13 +81,11 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return nil
}
// Read ISRC from file
metadata, err := ReadMetadata(path)
if err != nil || metadata.ISRC == "" {
return nil
}
// Store in index (uppercase for case-insensitive matching)
idx.index[strings.ToUpper(metadata.ISRC)] = path
fileCount++
return nil
@@ -82,7 +94,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
// Cache the index
isrcIndexCacheMu.Lock()
isrcIndexCache[outputDir] = idx
isrcIndexCacheMu.Unlock()
@@ -90,7 +101,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// lookup checks if an ISRC exists in the index (internal, returns bool)
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
if isrc == "" {
return "", false
@@ -103,6 +113,18 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
return path, exists
}
// remove deletes an ISRC entry from the index (internal use)
func (idx *ISRCIndex) remove(isrc string) {
if isrc == "" {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
delete(idx.index, strings.ToUpper(isrc))
}
// Lookup checks if an ISRC exists in the index (gomobile compatible)
// Returns filepath if found, empty string if not found
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
@@ -138,7 +160,18 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
// Use index for fast lookup
idx := GetISRCIndex(outputDir)
return idx.lookup(isrc)
filePath, exists := idx.lookup(isrc)
if !exists {
return "", false
}
if !CheckFileExists(filePath) {
// Stale index entry; remove it and return not found.
idx.remove(isrc)
return "", false
}
return filePath, true
}
// CheckISRCExists is the exported version for gomobile (returns string, error)
@@ -170,7 +203,6 @@ type FileExistenceResult struct {
// It builds an ISRC index from the output directory once, then checks all tracks against it
// Same implementation as PC version for consistency
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
// Parse input JSON
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
@@ -182,10 +214,8 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
results := make([]FileExistenceResult, len(tracks))
// Build ISRC index from output directory (scan once)
isrcIdx := GetISRCIndex(outputDir)
// Check each track against the index (parallel)
var wg sync.WaitGroup
for i, track := range tracks {
wg.Add(1)
@@ -216,7 +246,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
wg.Wait()
// Return results as JSON
resultJSON, err := json.Marshal(results)
if err != nil {
return "", fmt.Errorf("failed to marshal results: %w", err)
+378 -52
View File
@@ -5,9 +5,12 @@ package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
)
// ParseSpotifyURL parses and validates a Spotify URL
@@ -150,6 +153,10 @@ type DownloadRequest struct {
ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
}
// DownloadResponse represents the result of a download
@@ -177,7 +184,6 @@ type DownloadResponse struct {
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
}
// DownloadResult is a generic result type for all downloaders
// DownloadResult is a generic result type for all downloaders
type DownloadResult struct {
FilePath string
@@ -208,6 +214,11 @@ func DownloadTrack(requestJSON string) (string, error) {
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
var result DownloadResult
var err error
@@ -271,10 +282,8 @@ func DownloadTrack(requestJSON string) (string, error) {
return errorResponse(err.Error())
}
// Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -300,7 +309,6 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -345,7 +353,11 @@ func DownloadWithFallback(requestJSON string) (string, error) {
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Build service order starting with preferred service
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
if preferredService == "" {
@@ -354,7 +366,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
// Create ordered list: preferred first, then others
services := []string{preferredService}
for _, s := range allServices {
if s != preferredService {
@@ -389,7 +400,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
}
} else {
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
}
err = tidalErr
@@ -408,7 +419,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
}
} else {
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
}
err = qobuzErr
@@ -427,17 +438,19 @@ func DownloadWithFallback(requestJSON string) (string, error) {
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
}
} else {
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
}
err = amazonErr
}
if err != nil && errors.Is(err, ErrDownloadCancelled) {
return errorResponse("Download cancelled")
}
if err == nil {
// Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -463,7 +476,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
@@ -518,7 +530,6 @@ func InitItemProgress(itemID string) {
// FinishItemProgress marks a download item as complete and removes tracking
func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID)
// Don't remove immediately - let Flutter poll one more time to see 100%
}
// ClearItemProgress removes progress tracking for a specific item
@@ -526,6 +537,11 @@ func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID)
}
// CancelDownload cancels an in-progress download for the given item.
func CancelDownload(itemID string) {
cancelDownload(itemID)
}
// CleanupConnections closes idle HTTP connections
// Call this periodically during large batch downloads to prevent TCP exhaustion
func CleanupConnections() {
@@ -541,10 +557,8 @@ func ReadFileMetadata(filePath string) (string, error) {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
// Also get audio quality info
quality, qualityErr := GetAudioQuality(filePath)
// Get duration from FLAC stream info
duration := 0
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
duration = int(quality.TotalSamples / int64(quality.SampleRate))
@@ -563,7 +577,6 @@ func ReadFileMetadata(filePath string) (string, error) {
"duration": duration,
}
// Add quality info if available
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
@@ -614,7 +627,6 @@ func PreBuildDuplicateIndex(outputDir string) error {
}
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
// Call this when files are deleted or moved
func InvalidateDuplicateIndex(outputDir string) {
InvalidateISRCCache(outputDir)
}
@@ -637,9 +649,11 @@ func SanitizeFilename(filename string) string {
// FetchLyrics fetches lyrics for a track from LRCLIB
// Returns JSON with lyrics data
func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
@@ -661,8 +675,8 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
// First tries to extract from file, then falls back to fetching from internet
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
// Try to extract from file first (much faster)
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
@@ -670,14 +684,13 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
}
}
// Fallback to fetching from internet
client := NewLyricsClient()
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return "", err
}
// Convert to LRC format with metadata headers (like PC version)
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
return lrcContent, nil
}
@@ -714,7 +727,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
return errorResponse("Invalid JSON: " + err.Error())
}
// Convert to PreWarmCacheRequest
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
@@ -726,7 +738,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
}
}
// Run in background
go PreWarmTrackCache(requests)
resp := map[string]interface{}{
@@ -846,7 +857,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
}
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer
// Useful when Spotify API is rate limited
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -855,14 +865,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
songlink := NewSongLinkClient()
deezerClient := GetDeezerClient()
// For tracks, we can use SongLink to get Deezer ID
if resourceType == "track" {
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
}
// Fetch metadata from Deezer
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
@@ -876,14 +884,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return string(jsonBytes), nil
}
// For albums, SongLink also provides mapping
if resourceType == "album" {
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
if err != nil {
return "", fmt.Errorf("could not find Deezer album: %w", err)
}
// Fetch album metadata from Deezer
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
@@ -906,10 +912,8 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Try Spotify first
client, err := NewSpotifyMetadataClient()
if err != nil {
// No Spotify credentials - fall through to Deezer fallback
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
} else {
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
@@ -921,15 +925,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return string(jsonBytes), nil
}
// Check if it's a rate limit error
errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
// Not a rate limit error, return original error
return "", err
}
}
// Rate limited - try Deezer fallback for tracks and albums
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
@@ -938,11 +939,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" {
// Convert to Deezer
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
}
// Artist and playlist not supported for fallback
if parsed.Type == "artist" {
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
}
@@ -1007,7 +1006,6 @@ func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
}
func errorResponse(msg string) (string, error) {
// Determine error type based on message
errorType := "unknown"
lowerMsg := strings.ToLower(msg)
@@ -1015,6 +1013,8 @@ func errorResponse(msg string) (string, error) {
strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") {
errorType = "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
errorType = "cancelled"
} else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
@@ -1094,7 +1094,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return "", err
}
// Initialize with saved settings
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
@@ -1245,7 +1244,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
return err
}
// Re-initialize extension with new settings
manager := GetExtensionManager()
return manager.InitializeExtension(extensionID, settings)
}
@@ -1344,7 +1342,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
return false
}
// Check if token is expired
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return false
}
@@ -1430,6 +1427,39 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
// ==================== EXTENSION CUSTOM SEARCH ====================
// EnrichTrackWithExtensionJSON enriches track metadata using the source extension
// This is called lazily before download starts, allowing extension to fetch real ISRC etc.
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
// Extension not found, return original track
return trackJSON, nil
}
if !ext.Manifest.IsMetadataProvider() {
return trackJSON, nil
}
var track ExtTrackMetadata
if err := json.Unmarshal([]byte(trackJSON), &track); err != nil {
return trackJSON, fmt.Errorf("failed to parse track: %w", err)
}
provider := NewExtensionProviderWrapper(ext)
enrichedTrack, err := provider.EnrichTrack(&track)
if err != nil {
return trackJSON, nil
}
jsonBytes, err := json.Marshal(enrichedTrack)
if err != nil {
return trackJSON, nil
}
return string(jsonBytes), nil
}
// CustomSearchWithExtensionJSON performs custom search using an extension
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
manager := GetExtensionManager()
@@ -1455,7 +1485,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
return "", err
}
// Convert to map format for Flutter, ensuring images field is set
result := make([]map[string]interface{}, len(tracks))
for i, track := range tracks {
result[i] = map[string]interface{}{
@@ -1471,6 +1500,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType, // track, album, or playlist
"album_type": track.AlbumType, // album, single, ep, compilation
}
}
@@ -1520,12 +1551,10 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
result := resultWithID.Result
extensionID := resultWithID.ExtensionID
// Check if result is nil (handler found but returned error)
if result == nil {
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
}
// Build response
response := map[string]interface{}{
"type": result.Type,
"extension_id": extensionID,
@@ -1533,7 +1562,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"cover_url": result.CoverURL,
}
// Add track if single track
if result.Track != nil {
response["track"] = map[string]interface{}{
"id": result.Track.ID,
@@ -1551,7 +1579,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
}
}
// Add tracks if multiple
if len(result.Tracks) > 0 {
tracks := make([]map[string]interface{}, len(result.Tracks))
for i, track := range result.Tracks {
@@ -1568,6 +1595,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
response["tracks"] = tracks
@@ -1582,16 +1611,66 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"cover_url": result.Album.CoverURL,
"release_date": result.Album.ReleaseDate,
"total_tracks": result.Album.TotalTracks,
"album_type": result.Album.AlbumType,
"provider_id": result.Album.ProviderID,
}
}
// Add artist info if present
if result.Artist != nil {
response["artist"] = map[string]interface{}{
"id": result.Artist.ID,
"name": result.Artist.Name,
"image_url": result.Artist.ImageURL,
artistResponse := map[string]interface{}{
"id": result.Artist.ID,
"name": result.Artist.Name,
"image_url": result.Artist.ImageURL,
"header_image": result.Artist.HeaderImage,
"listeners": result.Artist.Listeners,
"provider_id": result.Artist.ProviderID,
}
if len(result.Artist.Albums) > 0 {
albums := make([]map[string]interface{}, len(result.Artist.Albums))
for i, album := range result.Artist.Albums {
albumType := album.AlbumType
if albumType == "" {
albumType = "album"
}
albums[i] = map[string]interface{}{
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"images": album.CoverURL,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": albumType,
"provider_id": album.ProviderID,
}
}
artistResponse["albums"] = albums
}
if len(result.Artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
for i, track := range result.Artist.TopTracks {
topTracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"spotify_id": track.SpotifyID,
}
}
artistResponse["top_tracks"] = topTracks
}
response["artist"] = artistResponse
}
jsonBytes, err := json.Marshal(response)
@@ -1613,6 +1692,253 @@ func FindURLHandlerJSON(url string) string {
return handler.extension.ID
}
// GetAlbumWithExtensionJSON gets album tracks using an extension
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
album, err := provider.GetAlbum(albumID)
if err != nil {
return "", err
}
if album == nil {
return "", fmt.Errorf("album not found")
}
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
}
tracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"cover_url": trackCover,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
response := map[string]interface{}{
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": album.AlbumType,
"tracks": tracks,
"provider_id": album.ProviderID,
}
jsonBytes, err := json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetPlaylistWithExtensionJSON gets playlist tracks using an extension
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
return extension.getPlaylist(%q);
}
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
return extension.getAlbum(%q);
}
return null;
})()
`, playlistID, playlistID)
result, err := RunWithTimeoutAndRecover(provider.vm, script, DefaultJSTimeout)
if err != nil {
return "", fmt.Errorf("getPlaylist failed: %w", err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("playlist not found")
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
// Parse into album metadata (same structure)
var album ExtAlbumMetadata
if err := json.Unmarshal(jsonBytes, &album); err != nil {
return "", fmt.Errorf("failed to parse playlist: %w", err)
}
album.ProviderID = ext.ID
for i := range album.Tracks {
album.Tracks[i].ProviderID = ext.ID
}
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
}
tracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"cover_url": trackCover,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
response := map[string]interface{}{
"id": album.ID,
"name": album.Name,
"owner": album.Artists,
"cover_url": album.CoverURL,
"total_tracks": album.TotalTracks,
"tracks": tracks,
"provider_id": album.ProviderID,
}
jsonBytes, err = json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetArtistWithExtensionJSON gets artist info and albums using an extension
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
artist, err := provider.GetArtist(artistID)
if err != nil {
return "", err
}
if artist == nil {
return "", fmt.Errorf("artist not found")
}
albums := make([]map[string]interface{}, len(artist.Albums))
for i, album := range artist.Albums {
albums[i] = map[string]interface{}{
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": album.AlbumType,
"provider_id": album.ProviderID,
}
}
response := map[string]interface{}{
"id": artist.ID,
"name": artist.Name,
"cover_url": artist.ImageURL,
"albums": albums,
"provider_id": artist.ProviderID,
}
// Add header image if present
if artist.HeaderImage != "" {
response["header_image"] = artist.HeaderImage
}
// Add listeners if present
if artist.Listeners > 0 {
response["listeners"] = artist.Listeners
}
// Add top tracks if present
if len(artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(artist.TopTracks))
for i, track := range artist.TopTracks {
topTracks[i] = map[string]interface{}{
"id": track.ID,
"name": track.Name,
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"spotify_id": track.SpotifyID,
}
}
response["top_tracks"] = topTracks
}
jsonBytes, err := json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// GetURLHandlersJSON returns all extensions that handle custom URLs
func GetURLHandlersJSON() (string, error) {
manager := GetExtensionManager()
+32 -58
View File
@@ -18,11 +18,9 @@ import (
// compareVersions compares two semantic version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int {
// Parse version parts
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
// Pad shorter version with zeros
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
@@ -52,12 +50,12 @@ func compareVersions(v1, v2 string) int {
type LoadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized)
VM *goja.Runtime `json:"-"`
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"` // Extension's data directory
SourceDir string `json:"source_dir"` // Where extension files are extracted
IconPath string `json:"icon_path"` // Full path to icon file (if exists)
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
// ExtensionManager manages all loaded extensions
@@ -68,7 +66,6 @@ type ExtensionManager struct {
dataDir string // Base directory for extension data
}
// Global extension manager instance
var (
globalExtManager *ExtensionManager
globalExtManagerOnce sync.Once
@@ -92,7 +89,6 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.extensionsDir = extensionsDir
m.dataDir = dataDir
// Create directories if they don't exist
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
return fmt.Errorf("failed to create extensions directory: %w", err)
}
@@ -117,7 +113,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte
var hasIndexJS bool
for _, file := range zipReader.File {
@@ -146,13 +141,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("Invalid extension package: index.js not found")
}
// Parse and validate manifest
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
// Check if extension already loaded - if so, try upgrade (check without holding lock for long)
m.mu.RLock()
existing, exists := m.extensions[manifest.Name]
var existingVersion string
@@ -164,7 +157,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
m.mu.RUnlock()
if exists {
// Check if this is an upgrade
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
// This is an upgrade - call UpgradeExtension
@@ -176,37 +168,43 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
}
// Now acquire write lock for the rest of the operation
m.mu.Lock()
defer m.mu.Unlock()
// Double-check extension wasn't added while we were waiting for lock
if _, exists := m.extensions[manifest.Name]; exists {
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
}
// Create extension directory
extDir := filepath.Join(m.extensionsDir, manifest.Name)
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
}
// Extract all files
// Extract all files (preserving directory structure)
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
// Get relative path within the zip
destPath := filepath.Join(extDir, filepath.Base(file.Name))
// Preserve relative path within the zip (support subdirectories)
// Clean the path to prevent path traversal attacks
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
continue
}
destPath := filepath.Join(extDir, relPath)
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
// Create destination file
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
// Copy content
srcFile, err := file.Open()
if err != nil {
destFile.Close()
@@ -221,13 +219,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
}
}
// Create data directory for extension
extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
// Create loaded extension
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
@@ -251,23 +247,19 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
// initializeVM creates and initializes the Goja VM for an extension
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
// Create new Goja runtime
vm := goja.New()
ext.VM = vm
// Read index.js
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
// Create extension runtime and register sandboxed APIs
runtime := NewExtensionRuntime(ext)
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
// Set up console.log for debugging
console := vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, len(call.Arguments))
@@ -279,12 +271,10 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
})
vm.Set("console", console)
// Set up registerExtension function
var registeredExtension goja.Value
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
registeredExtension = call.Arguments[0]
// Also set it as global 'extension' variable for later access
vm.Set("extension", call.Arguments[0])
}
return goja.Undefined()
@@ -394,7 +384,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
for _, entry := range entries {
if entry.IsDir() {
// Check if it's an extracted extension directory
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
if _, err := os.Stat(manifestPath); err == nil {
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
@@ -406,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
}
}
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
// Load from package file
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
@@ -425,7 +413,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
m.mu.Lock()
defer m.mu.Unlock()
// Read manifest
manifestPath := filepath.Join(dirPath, "manifest.json")
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
@@ -438,24 +425,21 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
// Check if index.js exists
indexPath := filepath.Join(dirPath, "index.js")
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
return nil, fmt.Errorf("Extension is missing index.js file")
}
// Check if extension already loaded - skip if already exists (for directory loading on startup)
if _, exists := m.extensions[manifest.Name]; exists {
return nil, fmt.Errorf("Extension '%s' is already loaded", manifest.DisplayName)
if existing, exists := m.extensions[manifest.Name]; exists {
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
return existing, nil
}
// Create data directory for extension
extDataDir := filepath.Join(m.dataDir, manifest.Name)
if err := os.MkdirAll(extDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
}
// Create loaded extension
ext := &LoadedExtension{
ID: manifest.Name,
Manifest: manifest,
@@ -528,7 +512,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte
var hasIndexJS bool
for _, file := range zipReader.File {
@@ -557,13 +540,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return nil, fmt.Errorf("Invalid extension package: index.js not found")
}
// Parse and validate manifest
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
}
// Check if extension exists
m.mu.RLock()
existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock()
@@ -599,27 +580,32 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
}
// Recreate extension directory
if err := os.MkdirAll(extDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create extension directory: %w", err)
}
// Extract all files from new package
for _, file := range zipReader.File {
if file.FileInfo().IsDir() {
continue
}
// Get relative path within the zip
destPath := filepath.Join(extDir, filepath.Base(file.Name))
relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
continue
}
destPath := filepath.Join(extDir, relPath)
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
// Create destination file
destFile, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
}
// Copy content
srcFile, err := file.Open()
if err != nil {
destFile.Close()
@@ -634,7 +620,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
}
}
// Create new loaded extension (reusing data directory, preserving enabled state)
ext := &LoadedExtension{
ID: newManifest.Name,
Manifest: newManifest,
@@ -683,7 +668,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
}
defer zipReader.Close()
// Find and read manifest.json
var manifestData []byte
for _, file := range zipReader.File {
name := filepath.Base(file.Name)
@@ -705,13 +689,11 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return nil, fmt.Errorf("manifest.json not found")
}
// Parse manifest
newManifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid manifest: %w", err)
}
// Check if extension exists
m.mu.RLock()
existing, exists := m.extensions[newManifest.Name]
m.mu.RUnlock()
@@ -727,7 +709,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
info.CurrentVersion = ""
info.CanUpgrade = false
} else {
// Compare versions
info.CurrentVersion = existing.Manifest.Version
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
}
@@ -780,7 +761,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
infos := make([]ExtensionInfo, len(extensions))
for i, ext := range extensions {
// Build permissions list
permissions := []string{}
for _, domain := range ext.Manifest.Permissions.Network {
permissions = append(permissions, "network:"+domain)
@@ -797,7 +777,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
status = "disabled"
}
// Check for icon file
iconPath := ""
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
@@ -805,7 +784,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
iconPath = possibleIcon
}
}
// Fallback: check for icon.png if not specified in manifest
if iconPath == "" && ext.SourceDir != "" {
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
if _, err := os.Stat(possibleIcon); err == nil {
@@ -862,13 +840,11 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
// Convert settings to JSON for passing to JS
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("Failed to save settings")
}
// Call initialize function
script := fmt.Sprintf(`
(function() {
var settings = %s;
@@ -892,7 +868,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return err
}
// Check result
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
@@ -948,7 +923,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return err
}
// Check result
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
+1
View File
@@ -29,6 +29,7 @@ const (
type ExtensionPermissions struct {
Network []string `json:"network"` // List of allowed domains
Storage bool `json:"storage"` // Whether extension can use storage API
File bool `json:"file"` // Whether extension can use file API
}
// ExtensionSetting defines a configurable setting for an extension
+246 -30
View File
@@ -3,10 +3,12 @@ package gobackend
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
@@ -28,6 +30,14 @@ type ExtTrackMetadata struct {
DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"`
ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
// Enrichment fields from Odesli/song.link
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
}
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
@@ -46,17 +56,21 @@ type ExtAlbumMetadata struct {
CoverURL string `json:"cover_url,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks"`
ProviderID string `json:"provider_id"`
}
// ExtArtistMetadata represents artist metadata from an extension
type ExtArtistMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
ProviderID string `json:"provider_id"`
ID string `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
Listeners int `json:"listeners,omitempty"` // Monthly listeners
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks
ProviderID string `json:"provider_id"`
}
// ExtSearchResult represents search results from an extension
@@ -140,8 +154,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
})()
`, query, limit)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("searchTracks timeout: extension took too long to respond")
}
return nil, fmt.Errorf("searchTracks failed: %w", err)
}
@@ -157,11 +174,21 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
}
var searchResult ExtSearchResult
// Try to parse as ExtSearchResult object first
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
return nil, fmt.Errorf("failed to parse search result: %w", err)
// If that fails, try parsing as array of tracks directly
var tracks []ExtTrackMetadata
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
}
// Wrap array in ExtSearchResult
searchResult = ExtSearchResult{
Tracks: tracks,
Total: len(tracks),
}
}
// Set provider ID on all tracks
for i := range searchResult.Tracks {
searchResult.Tracks[i].ProviderID = p.extension.ID
}
@@ -188,8 +215,11 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
})()
`, trackID)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("getTrack timeout: extension took too long to respond")
}
return nil, fmt.Errorf("getTrack failed: %w", err)
}
@@ -231,8 +261,11 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
})()
`, albumID)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("getAlbum timeout: extension took too long to respond")
}
return nil, fmt.Errorf("getAlbum failed: %w", err)
}
@@ -277,8 +310,11 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
})()
`, artistID)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("getArtist timeout: extension took too long to respond")
}
return nil, fmt.Errorf("getArtist failed: %w", err)
}
@@ -301,6 +337,72 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return &artist, nil
}
// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC)
// This is called lazily when download starts, not when playlist/album is loaded
// Extension should implement enrichTrack(track) function that returns enriched track
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return track, nil // Not a metadata provider, return as-is
}
if !p.extension.Enabled {
return track, nil // Extension disabled, return as-is
}
// Convert track to JSON for passing to JS
trackJSON, err := json.Marshal(track)
if err != nil {
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
return track, nil // Return original on error
}
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.enrichTrack === 'function') {
var track = %s;
return extension.enrichTrack(track);
}
return null;
})()
`, string(trackJSON))
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID)
} else {
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
}
return track, nil // Return original on error
}
// If extension doesn't implement enrichTrack or returns null, return original
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return track, nil
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
GoLog("[Extension] EnrichTrack: failed to marshal result: %v\n", err)
return track, nil
}
var enrichedTrack ExtTrackMetadata
if err := json.Unmarshal(jsonBytes, &enrichedTrack); err != nil {
GoLog("[Extension] EnrichTrack: failed to parse enriched track: %v\n", err)
return track, nil
}
// Preserve provider ID
enrichedTrack.ProviderID = track.ProviderID
GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n",
p.extension.ID, track.ISRC, enrichedTrack.ISRC)
return &enrichedTrack, nil
}
// ==================== Download Provider Methods ====================
// CheckAvailability checks if a track is available for download
@@ -322,8 +424,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
})()
`, isrc, trackName, artistName)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("checkAvailability timeout: extension took too long to respond")
}
return nil, fmt.Errorf("checkAvailability failed: %w", err)
}
@@ -364,8 +469,11 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
})()
`, trackID, quality)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("getDownloadUrl timeout: extension took too long to respond")
}
return nil, fmt.Errorf("getDownloadUrl failed: %w", err)
}
@@ -387,6 +495,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return &urlResult, nil
}
// ExtDownloadTimeout is longer for extension download operations (5 minutes)
const ExtDownloadTimeout = 5 * time.Minute
// Download downloads a track with progress reporting
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
@@ -424,12 +535,19 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
})()
`, trackID, quality, outputPath)
result, err := p.vm.RunString(script)
// Use longer timeout for downloads (5 minutes)
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
if err != nil {
errMsg := err.Error()
errType := "script_error"
if IsTimeoutError(err) {
errMsg = "download timeout: extension took too long to complete"
errType = "timeout"
}
return &ExtDownloadResult{
Success: false,
ErrorMessage: err.Error(),
ErrorType: "script_error",
ErrorMessage: errMsg,
ErrorType: errType,
}, nil
}
@@ -595,13 +713,61 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
var lastErr error
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
// LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC)
// This is done lazily at download time, not when playlist/album is loaded
if req.Source != "" && !isBuiltInProvider(req.Source) {
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
provider := NewExtensionProviderWrapper(ext)
trackMeta := &ExtTrackMetadata{
ID: req.SpotifyID,
Name: req.TrackName,
Artists: req.ArtistName,
AlbumName: req.AlbumName,
DurationMS: req.DurationMS,
ISRC: req.ISRC,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ProviderID: req.Source,
}
enrichedTrack, err := provider.EnrichTrack(trackMeta)
if err == nil && enrichedTrack != nil {
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
req.ISRC = enrichedTrack.ISRC
}
if enrichedTrack.TidalID != "" {
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
req.TidalID = enrichedTrack.TidalID
}
if enrichedTrack.QobuzID != "" {
GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID)
req.QobuzID = enrichedTrack.QobuzID
}
if enrichedTrack.DeezerID != "" {
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
req.DeezerID = enrichedTrack.DeezerID
}
if enrichedTrack.Name != "" {
req.TrackName = enrichedTrack.Name
}
if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists
}
}
}
}
// If source extension is specified, try it first before the priority list
if req.Source != "" && !isBuiltInProvider(req.Source) {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
// Check if this extension wants to skip built-in fallback
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
provider := NewExtensionProviderWrapper(ext)
@@ -612,7 +778,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
// Build output path
outputPath := buildOutputPath(req)
// Download directly using the track ID from the extension
@@ -668,6 +833,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: req.Source,
}, nil
}
lastErr = err
} else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage)
@@ -712,6 +885,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
return result, nil
}
if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: providerID,
}, nil
}
lastErr = err
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
}
@@ -729,7 +910,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext)
// Check availability first
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
if err != nil || !availability.Available {
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
@@ -739,12 +919,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
// Build output path
outputPath := buildOutputPath(req)
// Download
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
// Update progress
if req.ItemID != "" {
SetItemProgress(req.ItemID, float64(percent), 0, 0)
}
@@ -797,6 +974,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return &DownloadResponse{
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: providerID,
}, nil
}
lastErr = err
} else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage)
@@ -947,8 +1132,11 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
})()
`, query, string(optionsJSON))
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("customSearch timeout: extension took too long to respond")
}
return nil, fmt.Errorf("customSearch failed: %w", err)
}
@@ -973,7 +1161,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
tracks = []ExtTrackMetadata{}
}
// Set provider ID on all tracks
for i := range tracks {
tracks[i].ProviderID = p.extension.ID
}
@@ -1013,8 +1200,11 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
})()
`, url)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("handleUrl timeout: extension took too long to respond")
}
return nil, fmt.Errorf("handleUrl failed: %w", err)
}
@@ -1040,6 +1230,24 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
for i := range handleResult.Tracks {
handleResult.Tracks[i].ProviderID = p.extension.ID
}
if handleResult.Album != nil {
handleResult.Album.ProviderID = p.extension.ID
for i := range handleResult.Album.Tracks {
handleResult.Album.Tracks[i].ProviderID = p.extension.ID
}
}
if handleResult.Artist != nil {
handleResult.Artist.ProviderID = p.extension.ID
for i := range handleResult.Artist.Albums {
handleResult.Artist.Albums[i].ProviderID = p.extension.ID
for j := range handleResult.Artist.Albums[i].Tracks {
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
}
}
for i := range handleResult.Artist.TopTracks {
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
}
}
return &handleResult, nil
}
@@ -1076,8 +1284,11 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
})()
`, string(sourceJSON), string(candidatesJSON))
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
if IsTimeoutError(err) {
return nil, fmt.Errorf("matchTrack timeout: extension took too long to respond")
}
return nil, fmt.Errorf("matchTrack failed: %w", err)
}
@@ -1111,6 +1322,9 @@ type PostProcessResult struct {
SampleRate int `json:"sample_rate,omitempty"`
}
// PostProcessTimeout is longer for post-processing (2 minutes)
const PostProcessTimeout = 2 * time.Minute
// PostProcess runs post-processing hooks on a downloaded file
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() {
@@ -1132,11 +1346,15 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
})()
`, filePath, string(metadataJSON), hookID)
result, err := p.vm.RunString(script)
result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout)
if err != nil {
errMsg := err.Error()
if IsTimeoutError(err) {
errMsg = "postProcess timeout: extension took too long to complete"
}
return &PostProcessResult{
Success: false,
Error: err.Error(),
Error: errMsg,
}, nil
}
@@ -1263,12 +1481,10 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
for _, provider := range providers {
hooks := provider.extension.Manifest.GetPostProcessingHooks()
for _, hook := range hooks {
// Check if hook is enabled (TODO: check user settings)
if !hook.DefaultEnabled {
continue
}
// Check if format is supported
ext := strings.ToLower(filepath.Ext(currentPath))
if len(hook.SupportedFormats) > 0 {
supported := false
+77 -11
View File
@@ -10,7 +10,8 @@ import (
"github.com/dop251/goja"
)
// Global auth state for extensions (stores pending auth codes)
const DefaultJSTimeout = 30 * time.Second
var (
extensionAuthState = make(map[string]*ExtensionAuthState)
extensionAuthStateMu sync.RWMutex
@@ -36,7 +37,6 @@ type PendingAuthRequest struct {
CallbackURL string
}
// Global pending auth requests (Flutter polls this)
var (
pendingAuthRequests = make(map[string]*PendingAuthRequest)
pendingAuthRequestsMu sync.RWMutex
@@ -49,7 +49,6 @@ func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
return pendingAuthRequests[extensionID]
}
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
func ClearPendingAuthRequest(extensionID string) {
pendingAuthRequestsMu.Lock()
defer pendingAuthRequestsMu.Unlock()
@@ -98,23 +97,90 @@ type ExtensionRuntime struct {
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
// Create a cookie jar for this extension
jar, _ := newSimpleCookieJar()
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
}
return &ExtensionRuntime{
runtime := &ExtensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
httpClient: client,
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
}
// Create HTTP client with redirect validation to prevent SSRF via open redirect
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Validate redirect target domain against allowed domains
domain := req.URL.Hostname()
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
// Also block redirects to private/local networks (SSRF protection)
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
}
// Default redirect limit (10)
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
},
}
runtime.httpClient = client
return runtime
}
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
type RedirectBlockedError struct {
Domain string
IsPrivate bool
}
func (e *RedirectBlockedError) Error() string {
if e.IsPrivate {
return "redirect blocked: private/local network access denied"
}
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
}
// isPrivateIP checks if a hostname resolves to a private/local IP address
func isPrivateIP(host string) bool {
// Block common private network patterns
// This is a simple check - for production, consider DNS resolution
privatePatterns := []string{
"localhost",
"127.",
"10.",
"172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.",
"172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.",
"192.168.",
"169.254.", // Link-local
"::1", // IPv6 localhost
"fc00:", // IPv6 private
"fe80:", // IPv6 link-local
}
hostLower := host
for _, pattern := range privatePatterns {
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
return true
}
}
// Also block .local domains
if len(host) > 6 && host[len(host)-6:] == ".local" {
return true
}
return false
}
// simpleCookieJar is a simple in-memory cookie jar
+63 -7
View File
@@ -8,25 +8,81 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/dop251/goja"
)
// ==================== File API (Sandboxed) ====================
// validatePath checks if the path is within the extension's data directory
// For absolute paths (from download queue), it allows them if they're valid
// List of allowed directories for file operations (set by Go backend for download operations)
var (
allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex
)
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
// This should be called by the Go backend when setting up download paths
func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
allowedDownloadDirs = dirs
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
}
// AddAllowedDownloadDir adds a directory to the allowed list
func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
absDir, err := filepath.Abs(dir)
if err == nil {
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
}
}
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock()
for _, allowedDir := range allowedDownloadDirs {
if strings.HasPrefix(absPath, allowedDir) {
return true
}
}
return false
}
// validatePath checks if the path is within the extension's sandbox
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
// Extensions should use relative paths for their own data storage
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
// Check if extension has file permission
if !r.manifest.Permissions.File {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
}
// Clean and resolve the path
cleanPath := filepath.Clean(path)
// If path is absolute, allow it (for download queue paths)
// This is safe because the Go backend controls what paths are passed
// SECURITY: Block absolute paths by default
// Only allow if path is in explicitly allowed download directories
if filepath.IsAbs(cleanPath) {
return cleanPath, nil
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Check if path is in allowed download directories
if isPathInAllowedDirs(absPath) {
return absPath, nil
}
// Block all other absolute paths
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
}
// For relative paths, join with data directory
// For relative paths, join with data directory (extension's sandbox)
fullPath := filepath.Join(r.dataDir, cleanPath)
// Resolve to absolute path
@@ -35,7 +91,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return "", fmt.Errorf("invalid path: %w", err)
}
// Ensure path is within data directory
// Ensure path is within data directory (prevent path traversal)
absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
+6
View File
@@ -29,6 +29,12 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
}
domain := parsed.Hostname()
// Block private/local network access (SSRF protection)
if isPrivateIP(domain) {
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
}
if !r.manifest.IsDomainAllowed(domain) {
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
}
+50 -8
View File
@@ -134,12 +134,48 @@ func (r *ExtensionRuntime) getCredentialsPath() string {
return filepath.Join(r.dataDir, ".credentials.enc")
}
// getEncryptionKey derives an encryption key from extension ID
func (r *ExtensionRuntime) getEncryptionKey() []byte {
// Use SHA256 of extension ID + salt as encryption key
salt := "spotiflac-ext-cred-v1"
hash := sha256.Sum256([]byte(r.extensionID + salt))
return hash[:]
// getSaltPath returns the path to the extension's encryption salt file
func (r *ExtensionRuntime) getSaltPath() string {
return filepath.Join(r.dataDir, ".cred_salt")
}
// getOrCreateSalt gets existing salt or creates a new random one
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath()
// Try to read existing salt
salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 {
return salt, nil
}
// Generate new random salt (32 bytes)
salt = make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
// Save salt to file
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err)
}
return salt, nil
}
// getEncryptionKey derives an encryption key from extension ID + random salt
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
// Get or create per-installation random salt
salt, err := r.getOrCreateSalt()
if err != nil {
return nil, err
}
// Combine extension ID + random salt for key derivation
// This makes each installation unique, preventing mass decryption attacks
combined := append([]byte(r.extensionID), salt...)
hash := sha256.Sum256(combined)
return hash[:], nil
}
// loadCredentials loads and decrypts credentials from disk
@@ -154,7 +190,10 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
}
// Decrypt the data
key := r.getEncryptionKey()
key, err := r.getEncryptionKey()
if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err)
}
decrypted, err := decryptAES(data, key)
if err != nil {
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
@@ -176,7 +215,10 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
}
// Encrypt the data
key := r.getEncryptionKey()
key, err := r.getEncryptionKey()
if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err)
}
encrypted, err := encryptAES(data, key)
if err != nil {
return fmt.Errorf("failed to encrypt credentials: %w", err)
+110
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"path/filepath"
"testing"
"github.com/dop251/goja"
@@ -137,6 +138,9 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
File: true, // Enable file permission for test
},
},
DataDir: tempDir,
}
@@ -166,6 +170,36 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
if nestedPath == "" {
t.Error("Expected non-empty nested path")
}
// Test absolute path should be blocked (security fix)
// Use platform-appropriate absolute path
var absPath string
if filepath.IsAbs("C:\\Windows\\System32") {
absPath = "C:\\Windows\\System32\\test.txt" // Windows
} else {
absPath = "/etc/passwd" // Unix
}
_, err = runtime.validatePath(absPath)
if err == nil {
t.Error("Expected absolute path to be blocked")
}
// Test that extension without file permission is blocked
extNoFile := &LoadedExtension{
ID: "test-ext-no-file",
Manifest: &ExtensionManifest{
Name: "test-ext-no-file",
Permissions: ExtensionPermissions{
File: false, // No file permission
},
},
DataDir: tempDir,
}
runtimeNoFile := NewExtensionRuntime(extNoFile)
_, err = runtimeNoFile.validatePath("test.txt")
if err == nil {
t.Error("Expected file access to be denied without file permission")
}
}
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
@@ -217,3 +251,79 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
t.Error("Expected non-empty JSON string")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
// Create extension with limited network permissions
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
// Test that private IPs are blocked (SSRF protection)
privateIPs := []string{
"http://localhost/admin",
"http://127.0.0.1/admin",
"http://192.168.1.1/admin",
"http://10.0.0.1/admin",
"http://172.16.0.1/admin",
"http://169.254.169.254/latest/meta-data/", // AWS metadata
"http://router.local/admin",
}
for _, url := range privateIPs {
err := runtime.validateDomain(url)
if err == nil {
t.Errorf("Expected private IP/host '%s' to be blocked", url)
}
}
// Test that allowed public domain still works
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
}
}
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
host string
expected bool
}{
// Private IPs should be blocked
{"localhost", true},
{"127.0.0.1", true},
{"127.0.0.2", true},
{"10.0.0.1", true},
{"10.255.255.255", true},
{"172.16.0.1", true},
{"172.31.255.255", true},
{"192.168.0.1", true},
{"192.168.255.255", true},
{"169.254.169.254", true}, // AWS metadata
{"router.local", true},
{"mydevice.local", true},
// Public IPs should be allowed
{"8.8.8.8", false},
{"1.1.1.1", false},
{"api.example.com", false},
{"google.com", false},
{"172.15.0.1", false}, // Just outside 172.16-31 range
{"172.32.0.1", false}, // Just outside 172.16-31 range
{"192.167.0.1", false}, // Not 192.168.x.x
}
for _, tt := range tests {
result := isPrivateIP(tt.host)
if result != tt.expected {
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
// Package gobackend provides timeout execution for extension JS code
package gobackend
import (
"context"
"fmt"
"sync"
"time"
"github.com/dop251/goja"
)
// JSExecutionError represents an error during JS execution
type JSExecutionError struct {
Message string
IsTimeout bool
}
func (e *JSExecutionError) Error() string {
return e.Message
}
// RunWithTimeout executes JavaScript code with a timeout
// Returns the result value and any error (including timeout)
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if timeout <= 0 {
timeout = DefaultJSTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Channel to receive result
type result struct {
value goja.Value
err error
}
resultCh := make(chan result, 1)
// Track if we've interrupted
var interrupted bool
var interruptMu sync.Mutex
// Run script in goroutine
go func() {
defer func() {
if r := recover(); r != nil {
// Check if this was our interrupt
interruptMu.Lock()
wasInterrupted := interrupted
interruptMu.Unlock()
if wasInterrupted {
resultCh <- result{nil, &JSExecutionError{
Message: "execution timeout exceeded",
IsTimeout: true,
}}
} else {
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
}
}
}()
val, err := vm.RunString(script)
resultCh <- result{val, err}
}()
// Wait for result or timeout
select {
case res := <-resultCh:
return res.value, res.err
case <-ctx.Done():
// Timeout - interrupt the VM
interruptMu.Lock()
interrupted = true
interruptMu.Unlock()
vm.Interrupt("execution timeout")
// Wait a bit for the goroutine to finish
select {
case res := <-resultCh:
// If we got a result after interrupt, it might be the timeout error
if res.err != nil {
return nil, res.err
}
return nil, &JSExecutionError{
Message: "execution timeout exceeded",
IsTimeout: true,
}
case <-time.After(1 * time.Second):
// Force return timeout error
return nil, &JSExecutionError{
Message: "execution timeout exceeded (force)",
IsTimeout: true,
}
}
}
}
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
// This should be used when you want to continue using the VM after a timeout
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
result, err := RunWithTimeout(vm, script, timeout)
// Clear any interrupt state so VM can be reused
vm.ClearInterrupt()
return result, err
}
// IsTimeoutError checks if an error is a timeout error
func IsTimeoutError(err error) bool {
if jsErr, ok := err.(*JSExecutionError); ok {
return jsErr.IsTimeout
}
return false
}
-7
View File
@@ -11,23 +11,18 @@ var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(filename string) string {
// Replace invalid characters with underscore
sanitized := invalidChars.ReplaceAllString(filename, "_")
// Remove leading/trailing spaces and dots
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".")
// Collapse multiple underscores
multiUnderscore := regexp.MustCompile(`_+`)
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
// Limit length (Android has 255 byte limit for filenames)
if len(sanitized) > 200 {
sanitized = sanitized[:200]
}
// Ensure not empty
if sanitized == "" {
sanitized = "untitled"
}
@@ -43,7 +38,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
result := template
// Replace placeholders
placeholders := map[string]string{
"{title}": getString(metadata, "title"),
"{artist}": getString(metadata, "artist"),
@@ -63,7 +57,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
// Trim leading/trailing whitespace to prevent filename issues
return strings.TrimSpace(s)
}
}
+13 -26
View File
@@ -20,13 +20,11 @@ import (
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
func getRandomUserAgent() string {
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
// Some APIs may block mobile User-Agents, so we use desktop format
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
winMajor := rand.Intn(2) + 10
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
chromePatch := rand.Intn(65) + 60 // Patch 60-125
chromeVersion := rand.Intn(25) + 100
chromeBuild := rand.Intn(1500) + 3000
chromePatch := rand.Intn(65) + 60
return fmt.Sprintf(
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
@@ -39,7 +37,6 @@ func getRandomUserAgent() string {
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
// Kept for potential future use
// func getRandomMacUserAgent() string {
// macMajor := rand.Intn(4) + 11 // macOS 11-14
// macMinor := rand.Intn(5) + 4 // Minor 4-8
@@ -66,7 +63,6 @@ func getRandomUserAgent() string {
// }
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
// Kept for potential future use
// func getRandomDesktopUserAgent() string {
// if rand.Intn(2) == 0 {
// return getRandomUserAgent() // Windows
@@ -74,17 +70,15 @@ func getRandomUserAgent() string {
// return getRandomMacUserAgent() // Mac
// }
// Default timeout values
const (
DefaultTimeout = 60 * time.Second // Default HTTP timeout
DownloadTimeout = 120 * time.Second // Timeout for file downloads
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
DefaultMaxRetries = 3 // Default retry count
DefaultRetryDelay = 1 * time.Second // Initial retry delay
DefaultTimeout = 60 * time.Second
DownloadTimeout = 120 * time.Second
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
)
// Shared transport with connection pooling to prevent TCP exhaustion
// Optimized for large file downloads (FLAC ~30-50MB)
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -96,27 +90,24 @@ var sharedTransport = &http.Transport{
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, // 64KB write buffer
ReadBufferSize: 64 * 1024, // 64KB read buffer
DisableCompression: true, // FLAC is already compressed
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: true,
}
// Shared HTTP client for general requests (reuses connections)
var sharedClient = &http.Client{
Transport: sharedTransport,
Timeout: DefaultTimeout,
}
// Shared HTTP client for downloads (longer timeout, reuses connections)
var downloadClient = &http.Client{
Transport: sharedTransport,
Timeout: DownloadTimeout,
}
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
// Uses shared transport for connection reuse
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Transport: sharedTransport,
@@ -124,18 +115,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
}
}
// GetSharedClient returns the shared HTTP client for general requests
func GetSharedClient() *http.Client {
return sharedClient
}
// GetDownloadClient returns the shared HTTP client for downloads
func GetDownloadClient() *http.Client {
return downloadClient
}
// CloseIdleConnections closes idle connections in the shared transport
// Call this periodically during large batch downloads to prevent connection buildup
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
}
@@ -146,7 +134,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
// Check for ISP blocking
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
+1 -5
View File
@@ -21,7 +21,7 @@ type LogBuffer struct {
entries []LogEntry
maxSize int
mu sync.RWMutex
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
loggingEnabled bool
}
var (
@@ -60,7 +60,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock()
defer lb.mu.Unlock()
// Skip if logging is disabled (except for errors which are always logged)
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
return
}
@@ -73,12 +72,10 @@ func (lb *LogBuffer) Add(level, tag, message string) {
}
if len(lb.entries) >= lb.maxSize {
// Remove oldest entry
lb.entries = lb.entries[1:]
}
lb.entries = append(lb.entries, entry)
// Also print to logcat for debugging
fmt.Printf("[%s] %s\n", tag, message)
}
@@ -91,7 +88,6 @@ func (lb *LogBuffer) GetAll() string {
return string(jsonBytes)
}
// getSince returns log entries since the given index (internal use)
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
lb.mu.RLock()
defer lb.mu.RUnlock()
+155 -9
View File
@@ -3,14 +3,100 @@ package gobackend
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// ========================================
// Lyrics Cache with TTL
// ========================================
const (
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours
durationToleranceSec = 10.0 // Duration matching tolerance in seconds
)
type lyricsCacheEntry struct {
response *LyricsResponse
expiresAt time.Time
}
type lyricsCache struct {
mu sync.RWMutex
cache map[string]*lyricsCacheEntry
}
var globalLyricsCache = &lyricsCache{
cache: make(map[string]*lyricsCacheEntry),
}
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
// Normalize key: lowercase, trim spaces
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
// Round duration to nearest 10 seconds for cache key
roundedDuration := math.Round(durationSec/10) * 10
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
}
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.generateKey(artist, track, durationSec)
entry, exists := c.cache[key]
if !exists {
return nil, false
}
// Check if expired
if time.Now().After(entry.expiresAt) {
return nil, false
}
return entry.response, true
}
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.generateKey(artist, track, durationSec)
c.cache[key] = &lyricsCacheEntry{
response: response,
expiresAt: time.Now().Add(lyricsCacheTTL),
}
}
// CleanExpired removes expired entries from cache
func (c *lyricsCache) CleanExpired() int {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
cleaned := 0
for key, entry := range c.cache {
if now.After(entry.expiresAt) {
delete(c.cache, key)
cleaned++
}
}
return cleaned
}
// Size returns current cache size
func (c *lyricsCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -86,7 +172,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
return c.parseLRCLibResponse(&lrcResp), nil
}
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
// durationSec: track duration in seconds, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
baseURL := "https://lrclib.net/api/search"
params := url.Values{}
params.Set("q", query)
@@ -118,6 +206,13 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return nil, fmt.Errorf("no lyrics found")
}
// Filter and score results based on duration matching and synced lyrics
bestMatch := c.findBestMatch(results, durationSec)
if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil
}
// Fallback: return first result with synced lyrics
for _, result := range results {
if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil
@@ -127,38 +222,89 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
return c.parseLRCLibResponse(&results[0]), nil
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
// Strategy 1: Direct match with artist and track name
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
// findBestMatch finds the best matching lyrics based on duration and sync status
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
for i := range results {
result := &results[i]
// Check duration match if target duration is provided
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches {
// Prefer synced lyrics over plain
if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil {
bestPlain = result
}
}
}
// Return synced first, then plain
if bestSynced != nil {
return bestSynced
}
return bestPlain
}
// durationMatches checks if two durations are within tolerance
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec
}
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Check cache first
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
cachedCopy.Source = cached.Source + " (cached)"
return &cachedCopy, nil
}
var lyrics *LyricsResponse
var err error
// Try exact match first
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Strategy 2: Try with simplified track name
// Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
// Strategy 3: Search with full query
// Search with duration matching
query := artistName + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB Search"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
// Strategy 4: Search with simplified query
// Search with simplified name and duration matching
if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB Search (simplified)"
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
}
+7 -79
View File
@@ -33,7 +33,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find or create vorbis comment block
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
@@ -52,7 +51,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
cmt = flacvorbis.New()
}
// Set metadata fields
setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album)
@@ -84,7 +82,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
// Update or add vorbis comment block
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -92,14 +89,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f.Meta = append(f.Meta, &cmtBlock)
}
// Add cover art if provided
if coverPath != "" {
if fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
} else {
// Remove existing picture blocks first (like PC version)
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -125,7 +120,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
}
}
// Save file
return f.Save(filePath)
}
@@ -137,7 +131,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find or create vorbis comment block
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
@@ -156,7 +149,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
cmt = flacvorbis.New()
}
// Set metadata fields
setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist)
setComment(cmt, "ALBUM", metadata.Album)
@@ -188,7 +180,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
}
// Update or add vorbis comment block
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -196,9 +187,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
f.Meta = append(f.Meta, &cmtBlock)
}
// Add cover art if provided
if len(coverData) > 0 {
// Remove existing picture blocks first
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
@@ -220,7 +209,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
}
}
// Save file
return f.Save(filePath)
}
@@ -257,7 +245,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
}
// Also try lowercase variant (some encoders use lowercase)
if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK")
if trackNum != "" {
@@ -269,7 +256,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
}
// Also try DISC variant
if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC")
if discNum != "" {
@@ -277,7 +263,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
}
}
// Try DATE variants
if metadata.Date == "" {
metadata.Date = getComment(cmt, "YEAR")
}
@@ -293,7 +278,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" {
return
}
// Remove existing (case-insensitive comparison for Vorbis comments)
keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- {
comment := cmt.Comments[i]
@@ -305,7 +289,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
}
}
}
// Add new
cmt.Comments = append(cmt.Comments, key+"="+value)
}
@@ -313,7 +296,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
keyUpper := strings.ToUpper(key) + "="
for _, comment := range cmt.Comments {
if len(comment) > len(key) {
// Case-insensitive comparison for Vorbis comments
commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper {
return comment[len(key)+1:]
@@ -323,7 +305,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
return ""
}
// fileExists checks if a file exists
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
@@ -381,13 +362,11 @@ func ExtractLyrics(filePath string) (string, error) {
continue
}
// Try LYRICS tag first
lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
// Fallback to UNSYNCEDLYRICS
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
@@ -415,16 +394,12 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
}
defer file.Close()
// Read first 4 bytes to detect file type
marker := make([]byte, 4)
if _, err := file.Read(marker); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
}
// Check if it's a FLAC file
if string(marker) == "fLaC" {
// Continue reading FLAC metadata
// Read metadata block header (4 bytes)
header := make([]byte, 4)
if _, err := file.Read(header); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
@@ -435,19 +410,15 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
}
// Read STREAMINFO block (34 bytes minimum)
streamInfo := make([]byte, 34)
if _, err := file.Read(streamInfo); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
}
// Parse sample rate (20 bits starting at byte 10)
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
// Parse bits per sample (5 bits)
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 |
@@ -461,17 +432,14 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
}, nil
}
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
// First 4 bytes are size, next 4 should be "ftyp"
file.Seek(0, 0) // Reset to beginning
file.Seek(0, 0)
header8 := make([]byte, 8)
if _, err := file.Read(header8); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
}
if string(header8[4:8]) == "ftyp" {
// It's an M4A/MP4 file, use M4A quality reader
file.Close() // Close before calling GetM4AQuality which opens the file again
file.Close()
return GetM4AQuality(filePath)
}
@@ -483,41 +451,33 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
// ========================================
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
// This is a simplified implementation that writes metadata to the file
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
// Read the entire file
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read M4A file: %w", err)
}
// Find moov atom position
moovPos := findAtom(data, "moov", 0)
if moovPos < 0 {
return fmt.Errorf("moov atom not found in M4A file")
}
// Find udta atom inside moov, or create one
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
udtaPos := findAtom(data, "udta", moovPos+8)
// Build new metadata atoms
metaAtom := buildMetaAtom(metadata, coverData)
var newData []byte
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
// udta exists, find meta inside it or replace
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
metaPos := findAtom(data, "meta", udtaPos+8)
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
// Replace existing meta atom
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
newData = append(newData, data[:metaPos]...)
newData = append(newData, metaAtom...)
newData = append(newData, data[metaPos+metaSize:]...)
} else {
// Add meta atom to udta
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
newUdtaSize := 8 + len(newUdtaContent)
newUdta := make([]byte, 4)
@@ -533,7 +493,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
newData = append(newData, data[udtaPos+udtaSize:]...)
}
} else {
// Create new udta with meta
udtaContent := metaAtom
udtaSize := 8 + len(udtaContent)
newUdta := make([]byte, 4)
@@ -544,21 +503,18 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
newUdta = append(newUdta, []byte("udta")...)
newUdta = append(newUdta, udtaContent...)
// Insert udta at end of moov
insertPos := moovPos + moovSize
newData = append(newData, data[:insertPos]...)
newData = append(newData, newUdta...)
newData = append(newData, data[insertPos:]...)
}
// Update moov size
newMoovSize := moovSize + len(newData) - len(data)
newData[moovPos] = byte(newMoovSize >> 24)
newData[moovPos+1] = byte(newMoovSize >> 16)
newData[moovPos+2] = byte(newMoovSize >> 8)
newData[moovPos+3] = byte(newMoovSize)
// Write back to file
if err := os.WriteFile(filePath, newData, 0644); err != nil {
return fmt.Errorf("failed to write M4A file: %w", err)
}
@@ -570,7 +526,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
// findAtom finds an atom by name starting from offset
func findAtom(data []byte, name string, offset int) int {
for i := offset; i < len(data)-8; {
size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
if size < 8 {
break
}
@@ -585,55 +541,44 @@ func findAtom(data []byte, name string, offset int) int {
// buildMetaAtom builds a complete meta atom with ilst containing metadata
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
// Build ilst content
var ilst []byte
// ©nam - Title
if metadata.Title != "" {
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
}
// ©ART - Artist
if metadata.Artist != "" {
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
}
// ©alb - Album
if metadata.Album != "" {
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
}
// aART - Album Artist
if metadata.AlbumArtist != "" {
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
}
// ©day - Year/Date
if metadata.Date != "" {
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
}
// trkn - Track Number
if metadata.TrackNumber > 0 {
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
}
// disk - Disc Number
if metadata.DiscNumber > 0 {
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
}
// ©lyr - Lyrics
if metadata.Lyrics != "" {
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
}
// covr - Cover Art
if len(coverData) > 0 {
ilst = append(ilst, buildCoverAtom(coverData)...)
}
// Build ilst atom
ilstSize := 8 + len(ilst)
ilstAtom := make([]byte, 4)
ilstAtom[0] = byte(ilstSize >> 24)
@@ -643,7 +588,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
ilstAtom = append(ilstAtom, []byte("ilst")...)
ilstAtom = append(ilstAtom, ilst...)
// Build hdlr atom (required for meta)
hdlr := []byte{
0, 0, 0, 33, // size = 33
'h', 'd', 'l', 'r',
@@ -656,7 +600,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
0, // null terminator
}
// Build meta atom
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
metaContent = append(metaContent, ilstAtom...)
@@ -676,7 +619,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value)
// data atom
dataSize := 16 + len(valueBytes)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
@@ -688,7 +630,6 @@ func buildTextAtom(name, value string) []byte {
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, valueBytes...)
// container atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -703,7 +644,6 @@ func buildTextAtom(name, value string) []byte {
// buildTrackNumberAtom builds trkn atom
func buildTrackNumberAtom(track, total int) []byte {
// data atom with track number
dataAtom := []byte{
0, 0, 0, 24, // size
'd', 'a', 't', 'a',
@@ -715,7 +655,6 @@ func buildTrackNumberAtom(track, total int) []byte {
0, 0, // padding
}
// trkn atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -730,7 +669,6 @@ func buildTrackNumberAtom(track, total int) []byte {
// buildDiscNumberAtom builds disk atom
func buildDiscNumberAtom(disc, total int) []byte {
// data atom with disc number
dataAtom := []byte{
0, 0, 0, 22, // size
'd', 'a', 't', 'a',
@@ -741,7 +679,6 @@ func buildDiscNumberAtom(disc, total int) []byte {
byte(total >> 8), byte(total), // total discs
}
// disk atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -756,13 +693,11 @@ func buildDiscNumberAtom(disc, total int) []byte {
// buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte {
// Detect image type (JPEG = 13, PNG = 14)
imageType := byte(13) // default JPEG
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14 // PNG
}
// data atom
dataSize := 16 + len(coverData)
dataAtom := make([]byte, 4)
dataAtom[0] = byte(dataSize >> 24)
@@ -774,7 +709,6 @@ func buildCoverAtom(coverData []byte) []byte {
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
dataAtom = append(dataAtom, coverData...)
// covr atom
atomSize := 8 + len(dataAtom)
atom := make([]byte, 4)
atom[0] = byte(atomSize >> 24)
@@ -794,24 +728,18 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
}
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
moovPos := findAtom(data, "moov", 0)
if moovPos < 0 {
return AudioQuality{}, fmt.Errorf("moov atom not found")
}
// Search for mp4a or alac atom which contains audio info
// This is a simplified search - real implementation would traverse the atom tree
for i := moovPos; i < len(data)-20; i++ {
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
if i+24 < len(data) {
sampleRate := int(data[i+22])<<8 | int(data[i+23])
// For AAC, bit depth is typically 16
bitDepth := 16
if string(data[i:i+4]) == "alac" {
// ALAC can have higher bit depth, check esds or alac specific data
bitDepth = 24 // Assume 24-bit for ALAC
bitDepth = 24
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
+7 -12
View File
@@ -35,7 +35,7 @@ func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, // Cache for 30 minutes
ttl: 30 * time.Minute,
}
})
return globalTrackIDCache
@@ -124,6 +124,7 @@ type ParallelDownloadResult struct {
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
// durationMs: track duration in milliseconds for lyrics matching
func FetchCoverAndLyricsParallel(
coverURL string,
maxQualityCover bool,
@@ -131,11 +132,11 @@ func FetchCoverAndLyricsParallel(
trackName string,
artistName string,
embedLyrics bool,
durationMs int64,
) *ParallelDownloadResult {
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
// Download cover in parallel
if coverURL != "" {
wg.Add(1)
go func() {
@@ -159,13 +160,13 @@ func FetchCoverAndLyricsParallel(
defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics
// Use LRC with metadata headers (like PC version)
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
@@ -202,12 +203,10 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache()
// Limit concurrent pre-warm requests
semaphore := make(chan struct{}, 3) // Max 3 concurrent
semaphore := make(chan struct{}, 3)
var wg sync.WaitGroup
for _, req := range requests {
// Skip if already cached
if cached := cache.Get(req.ISRC); cached != nil {
continue
}
@@ -252,11 +251,9 @@ func preWarmQobuzCache(isrc string) {
}
func preWarmAmazonCache(isrc, spotifyID string) {
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
// Store Amazon URL in cache (using ISRC as key)
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
}
@@ -270,10 +267,8 @@ func preWarmAmazonCache(isrc, spotifyID string) {
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
// Parse JSON (simplified - in production use proper JSON parsing)
// For now, this is called from exports.go with proper parsing
go PreWarmTrackCache(requests) // Run in background
go PreWarmTrackCache(requests)
return nil
}
+4 -6
View File
@@ -44,16 +44,14 @@ var (
)
// getProgress returns current download progress from multi-progress system
// Returns first active item's progress for backward compatibility
func getProgress() DownloadProgress {
multiMu.RLock()
defer multiMu.RUnlock()
// Find first active item
for _, item := range multiProgress.Items {
return DownloadProgress{
CurrentFile: item.ItemID,
Progress: item.Progress * 100, // Convert to percentage
Progress: item.Progress * 100,
BytesTotal: item.BytesTotal,
BytesReceived: item.BytesReceived,
IsDownloading: item.IsDownloading,
@@ -240,16 +238,16 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
// Write implements io.Writer with threshold-based progress updates and speed tracking
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
return 0, ErrDownloadCancelled
}
n, err := pw.writer.Write(p)
if err != nil {
return n, err
}
pw.current += int64(n)
// Update progress when we've received at least 64KB since last update
// Also update on first write to show download has started
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
// Calculate speed (MB/s) based on bytes received since last update
now := time.Now()
elapsed := now.Sub(pw.lastTime).Seconds()
var speedMBps float64
+149 -122
View File
@@ -2,8 +2,10 @@ package gobackend
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -23,7 +25,6 @@ type QobuzDownloader struct {
}
var (
// Global Qobuz downloader instance for connection reuse
globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once
)
@@ -64,28 +65,24 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
expectedArtists := qobuzSplitArtists(normExpected)
foundArtists := qobuzSplitArtists(normFound)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
for _, exp := range expectedArtists {
for _, fnd := range foundArtists {
if exp == fnd {
return true
}
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
if qobuzSameWordsUnordered(exp, fnd) {
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
}
}
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := qobuzIsLatinScript(expectedArtist)
foundLatin := qobuzIsLatinScript(foundArtist)
if expectedLatin != foundLatin {
@@ -96,6 +93,67 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return false
}
// qobuzSplitArtists splits artist string by common separators
func qobuzSplitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
normalized = strings.ReplaceAll(normalized, " ft ", "|")
normalized = strings.ReplaceAll(normalized, " & ", "|")
normalized = strings.ReplaceAll(normalized, " and ", "|")
normalized = strings.ReplaceAll(normalized, ", ", "|")
normalized = strings.ReplaceAll(normalized, " x ", "|")
parts := strings.Split(normalized, "|")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func qobuzSameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false
}
// Sort and compare
sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA)
copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] {
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
}
if sortedB[i] > sortedB[j] {
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
}
}
}
for i := range sortedA {
if sortedA[i] != sortedB[i] {
return false
}
}
return true
}
// qobuzTitlesMatch checks if track titles are similar enough
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
@@ -303,6 +361,35 @@ func NewQobuzDownloader() *QobuzDownloader {
return globalQobuzDownloader
}
// GetTrackByID fetches track info directly by Qobuz track ID
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Qobuz API: /track/get?track_id=XXX
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
req, err := http.NewRequest("GET", trackURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
}
var track QobuzTrack
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
return nil, err
}
return &track, nil
}
// GetAvailableAPIs returns list of available Qobuz APIs
// Uses same APIs as PC version for compatibility
func (q *QobuzDownloader) GetAvailableAPIs() []string {
@@ -702,7 +789,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
Error string `json:"error"`
}
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf(errorResp.Error), duration: time.Since(reqStart)}
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
return
}
@@ -725,12 +812,10 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
// Collect results - return first success
var errors []string
var firstSuccess *qobuzAPIResult
for i := 0; i < len(apis); i++ {
result := <-resultChan
if result.err == nil && firstSuccess == nil {
firstSuccess = &result
if result.err == nil {
GoLog("[Qobuz] [Parallel] ✓ Got response from %s in %v\n", result.apiURL, result.duration)
// Drain remaining results to avoid goroutine leaks
@@ -741,91 +826,19 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
}(len(apis) - i - 1)
GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
return firstSuccess.apiURL, firstSuccess.downloadURL, nil
} else if result.err != nil {
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
return result.apiURL, result.downloadURL, nil
}
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
}
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
}
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
// Uses same URL format as PC version: /api/stream?trackId={id}&quality={quality}
func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available")
}
client := NewHTTPClientWithTimeout(DefaultTimeout)
retryConfig := DefaultRetryConfig()
var errors []string
for _, apiURL := range apis {
// All APIs now use same format: https://domain/api/stream?trackId={id}&quality={quality}
// The apiURL already includes the path, just append trackID and quality
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
GoLog("[Qobuz] Trying: %s\n", reqURL)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
resp, err := DoRequestWithRetry(client, req, retryConfig)
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
body, err := ReadResponseBody(resp)
resp.Body.Close()
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
continue
}
// Check if response is HTML (error page)
if len(body) > 0 && body[0] == '<' {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "received HTML instead of JSON"))
continue
}
// Check for error in JSON response
var errorResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, errorResp.Error))
continue
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "invalid JSON: "+err.Error()))
continue
}
if result.URL != "" {
GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
return apiURL, result.URL, nil
}
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL in response"))
}
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
@@ -834,7 +847,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("no Qobuz API available")
}
// Use parallel approach - request from all APIs simultaneously
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
if err != nil {
return "", err
@@ -845,19 +857,30 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
// Initialize item progress (required for all downloads)
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
@@ -867,7 +890,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -877,16 +899,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return err
}
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body)
}
@@ -894,9 +913,11 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
@@ -935,19 +956,31 @@ type QobuzDownloadResult struct {
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *QobuzTrack
var err error
if req.QobuzID != "" {
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
var trackID int64
if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackByID(trackID)
if err != nil {
GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
}
}
}
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID
@@ -1002,7 +1035,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
// Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -1014,7 +1046,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
@@ -1033,12 +1064,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
// Get actual quality from track metadata
actualBitDepth := track.MaximumBitDepth
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
// Get download URL using parallel API requests
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
if err != nil {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
@@ -1056,27 +1085,26 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return QobuzDownloadResult{}, ErrDownloadCancelled
}
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Embed metadata using parallel-fetched cover data
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
albumName := track.Album.Title
if req.AlbumName != "" {
albumName = req.AlbumName
@@ -1094,7 +1122,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
ISRC: track.ISRC,
}
// Use cover data from parallel fetch
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
-6
View File
@@ -30,31 +30,25 @@ func (r *RateLimiter) WaitForSlot() {
now := time.Now()
// Remove timestamps outside the window
r.cleanOldTimestamps(now)
// If under limit, record and return immediately
if len(r.timestamps) < r.maxRequests {
r.timestamps = append(r.timestamps, now)
return
}
// Calculate wait time until oldest timestamp expires
oldestTimestamp := r.timestamps[0]
waitUntil := oldestTimestamp.Add(r.window)
waitDuration := waitUntil.Sub(now)
if waitDuration > 0 {
// Release lock while waiting
r.mu.Unlock()
time.Sleep(waitDuration)
r.mu.Lock()
// Clean again after waiting
r.cleanOldTimestamps(time.Now())
}
// Record this request
r.timestamps = append(r.timestamps, time.Now())
}
+1 -30
View File
@@ -31,7 +31,6 @@ type TrackAvailability struct {
}
var (
// Global SongLink client instance for connection reuse
globalSongLinkClient *SongLinkClient
songLinkClientOnce sync.Once
)
@@ -40,7 +39,7 @@ var (
func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
client: NewHTTPClientWithTimeout(SongLinkTimeout),
}
})
return globalSongLinkClient
@@ -48,15 +47,12 @@ func NewSongLinkClient() *SongLinkClient {
// CheckTrackAvailability checks track availability on streaming platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Validate Spotify ID format (should be 22 characters alphanumeric)
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
// Use global rate limiter - blocks until request is allowed
songLinkRateLimiter.WaitForSlot()
// Build API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -68,7 +64,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Use retry logic with User-Agent
retryConfig := DefaultRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
@@ -76,7 +71,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
}
@@ -109,27 +103,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
SpotifyID: spotifyTrackID,
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
@@ -191,12 +180,9 @@ func checkQobuzAvailability(isrc string) bool {
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
func extractDeezerIDFromURL(deezerURL string) string {
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
parts := strings.Split(deezerURL, "/")
if len(parts) > 0 {
// Get the last part which should be the ID
lastPart := parts[len(parts)-1]
// Remove any query parameters
if idx := strings.Index(lastPart, "?"); idx > 0 {
lastPart = lastPart[:idx]
}
@@ -274,7 +260,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
SpotifyID: spotifyAlbumID,
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
@@ -309,13 +294,10 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return nil, fmt.Errorf("deezer track ID is empty")
}
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
// Build Deezer URL
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
// Build API URL using Deezer URL as source
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
@@ -371,25 +353,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
DeezerID: deezerTrackID,
}
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
// Extract Spotify ID from URL
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer URL
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.DeezerURL = deezerLink.URL
}
@@ -459,24 +436,20 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability := &TrackAvailability{}
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
@@ -488,10 +461,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
// extractSpotifyIDFromURL extracts Spotify track ID from URL
func extractSpotifyIDFromURL(spotifyURL string) string {
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
parts := strings.Split(spotifyURL, "/track/")
if len(parts) > 1 {
// Get the ID part and remove any query parameters
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
+1 -15
View File
@@ -84,12 +84,10 @@ func HasSpotifyCredentials() bool {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" {
return true
}
// Check environment variables
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
return true
}
@@ -102,12 +100,10 @@ func getCredentials() (string, string, error) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret, nil
}
// Check environment variables
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
@@ -115,14 +111,12 @@ func getCredentials() (string, string, error) {
return clientID, clientSecret, nil
}
// No credentials available
return "", "", ErrNoSpotifyCredentials
}
// NewSpotifyMetadataClient creates a new Spotify client
// Returns error if credentials are not configured
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
// Get credentials - will error if not configured
clientID, clientSecret, err := getCredentials()
if err != nil {
return nil, err
@@ -131,7 +125,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
@@ -393,10 +387,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
// SearchAll searches for tracks and artists on Spotify
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
// Create cache key
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
@@ -456,7 +448,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
})
}
// Limit artists to artistLimit
artistCount := len(response.Artists.Items)
if artistCount > artistLimit {
artistCount = artistLimit
@@ -473,7 +464,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
})
}
// Store in cache
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
@@ -510,7 +500,6 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
}
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
@@ -610,7 +599,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
TrackList: tracks,
}
// Store in cache
c.cacheMu.Lock()
c.albumCache[albumID] = &cacheEntry{
data: result,
@@ -768,7 +756,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
@@ -856,7 +843,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Albums: albums,
}
// Store in cache
c.cacheMu.Lock()
c.artistCache[artistID] = &cacheEntry{
data: result,
+192 -213
View File
@@ -2,9 +2,11 @@ package gobackend
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
@@ -29,7 +31,6 @@ type TidalDownloader struct {
}
var (
// Global Tidal downloader instance for token reuse
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
)
@@ -116,7 +117,6 @@ func NewTidalDownloader() *TidalDownloader {
clientSecret: string(clientSecret),
}
// Get first available API
apis := globalTidalDownloader.GetAvailableAPIs()
if len(apis) > 0 {
globalTidalDownloader.apiURL = apis[0]
@@ -128,16 +128,14 @@ func NewTidalDownloader() *TidalDownloader {
// GetAvailableAPIs returns list of available Tidal APIs
func (t *TidalDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{
// Priority 1: APIs that return FULL tracks (not PREVIEW)
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - returns FULL
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
// Priority 2: qqdl.site APIs (often return PREVIEW only)
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
"dGlkYWwua2lub3BsdXMub25saW5l",
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
"dHJpdG9uLnNxdWlkLnd0Zg==",
"dm9nZWwucXFkbC5zaXRl",
"bWF1cy5xcWRsLnNpdGU=",
"aHVuZC5xcWRsLnNpdGU=",
"a2F0emUucXFkbC5zaXRl",
"d29sZi5xcWRsLnNpdGU=",
}
var apis []string
@@ -157,7 +155,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
t.tokenMu.Lock()
defer t.tokenMu.Unlock()
// Return cached token if still valid (with 60s buffer)
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
return t.cachedToken, nil
}
@@ -192,7 +189,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
return "", err
}
// Cache the token
t.cachedToken = result.AccessToken
if result.ExpiresIn > 0 {
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
@@ -384,22 +380,17 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
queries = append(queries, artistName+" "+trackName)
}
// Strategy 2: Track name only
if trackName != "" {
queries = append(queries, trackName)
}
// Strategy 3: Romaji versions if Japanese detected (NEW - from PC version)
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
// Convert to romaji (hiragana/katakana only, kanji stays)
romajiTrack := JapaneseToRomaji(trackName)
romajiArtist := JapaneseToRomaji(artistName)
// Clean and remove ALL non-ASCII characters (including kanji)
cleanRomajiTrack := CleanToASCII(romajiTrack)
cleanRomajiArtist := CleanToASCII(romajiArtist)
// Artist + Track romaji (cleaned to ASCII only)
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
if !containsQuery(queries, romajiQuery) {
@@ -408,14 +399,12 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
}
// Track romaji only (cleaned)
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
if !containsQuery(queries, cleanRomajiTrack) {
queries = append(queries, cleanRomajiTrack)
}
}
// Also try with partial romaji (artist + cleaned track)
if artistName != "" && cleanRomajiTrack != "" {
partialQuery := artistName + " " + cleanRomajiTrack
if !containsQuery(queries, partialQuery) {
@@ -424,7 +413,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
}
// Strategy 4: Artist only as last resort
if artistName != "" {
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
if artistOnly != "" && !containsQuery(queries, artistOnly) {
@@ -434,7 +422,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
// Collect all search results from all queries
var allTracks []TidalTrack
searchedQueries := make(map[string]bool)
@@ -484,7 +471,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
for i := range result.Items {
if result.Items[i].ISRC == spotifyISRC {
track := &result.Items[i]
// Verify duration if provided
if expectedDuration > 0 {
durationDiff := track.Duration - expectedDuration
if durationDiff < 0 {
@@ -494,7 +480,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
return track, nil
}
// Duration mismatch, continue searching
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
expectedDuration, track.Duration)
} else {
@@ -513,7 +498,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
return nil, fmt.Errorf("no tracks found for any search query")
}
// Priority 1: Match by ISRC (exact match) WITH title verification
if spotifyISRC != "" {
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
var isrcMatches []*TidalTrack
@@ -525,7 +509,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
if len(isrcMatches) > 0 {
// Verify duration first (most important check)
if expectedDuration > 0 {
var durationVerifiedMatches []*TidalTrack
for _, track := range isrcMatches {
@@ -533,37 +516,31 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 3 seconds tolerance for duration (same as PC version)
if durationDiff <= 3 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
// Return first duration-verified match
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't - this is likely wrong version
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
expectedDuration, isrcMatches[0].Duration)
}
// No duration to verify, just return first ISRC match
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
// If ISRC was provided but no match found, return error
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
}
// Priority 2: Match by duration (within tolerance) + prefer best quality
if expectedDuration > 0 {
tolerance := 3 // 3 seconds tolerance
var durationMatches []*TidalTrack
@@ -580,7 +557,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
if len(durationMatches) > 0 {
// Find best quality among duration matches
bestMatch := durationMatches[0]
for _, track := range durationMatches {
for _, tag := range track.MediaMetadata.Tags {
@@ -596,7 +572,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
}
// Priority 3: Just take the best quality from first results
bestMatch := &allTracks[0]
for i := range allTracks {
track := &allTracks[i]
@@ -660,12 +635,10 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
resultChan := make(chan tidalAPIResult, len(apis))
startTime := time.Now()
// Start all requests in parallel
for _, apiURL := range apis {
go func(api string) {
reqStart := time.Now()
// Create client with timeout for parallel requests
client := &http.Client{
Timeout: 15 * time.Second,
}
@@ -696,7 +669,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
return
}
// Try v2 format first (object with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
@@ -714,7 +686,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
return
}
// Fallback to v1 format (array with OriginalTrackUrl)
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
@@ -736,15 +707,11 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
}(apiURL)
}
// Collect results - return first success
var errors []string
var firstSuccess *tidalAPIResult
for i := 0; i < len(apis); i++ {
result := <-resultChan
if result.err == nil && firstSuccess == nil {
// First success - use this one
firstSuccess = &result
if result.err == nil {
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
@@ -756,109 +723,19 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
}(len(apis) - i - 1)
GoLog("[Tidal] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
return firstSuccess.apiURL, firstSuccess.info, nil
} else if result.err != nil {
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
return result.apiURL, result.info, nil
}
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
}
GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
}
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
// Returns the first successful result (supports both v1 and v2 API formats)
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
}
client := NewHTTPClientWithTimeout(DefaultTimeout)
retryConfig := DefaultRetryConfig()
var errors []string
for _, apiURL := range apis {
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
GoLog("[Tidal] Trying API: %s\n", reqURL)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
resp, err := DoRequestWithRetry(client, req, retryConfig)
if err != nil {
GoLog("[Tidal] API error: %v\n", err)
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
body, err := ReadResponseBody(resp)
resp.Body.Close()
if err != nil {
GoLog("[Tidal] Read body error: %v\n", err)
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
continue
}
// Log response preview
bodyPreview := string(body)
if len(bodyPreview) > 300 {
bodyPreview = bodyPreview[:300] + "..."
}
GoLog("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview)
// Try v2 format first (object with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
GoLog("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation)
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
if v2Response.Data.AssetPresentation == "PREVIEW" {
GoLog("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL"))
continue
}
GoLog("[Tidal] ✓ Got FULL track from %s\n", apiURL)
info := TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
SampleRate: v2Response.Data.SampleRate,
}
return apiURL, info, nil
}
// Fallback to v1 format (array with OriginalTrackUrl)
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
// v1 format doesn't have quality info, assume 16-bit/44.1kHz
info := TidalDownloadInfo{
URL: item.OriginalTrackURL,
BitDepth: 16,
SampleRate: 44100,
}
return apiURL, info, nil
}
}
}
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
}
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
}
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
@@ -867,7 +744,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
}
// Use parallel approach - request from all APIs simultaneously
_, info, err := getDownloadURLParallel(apis, trackID, quality)
if err != nil {
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
@@ -885,16 +761,13 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
manifestStr := string(manifestBytes)
// Debug: log first 500 chars of manifest for debugging
manifestPreview := manifestStr
if len(manifestPreview) > 500 {
manifestPreview = manifestPreview[:500] + "..."
}
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
// Check if it's BTS format (JSON) or DASH format (XML)
if strings.HasPrefix(manifestStr, "{") {
// BTS format - JSON with direct URLs
var btsManifest TidalBTSManifest
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
@@ -907,7 +780,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return btsManifest.URLs[0], "", nil, nil
}
// DASH format - XML with segments
var mpd MPD
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
@@ -918,7 +790,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaTemplate := segTemplate.Media
if initURL == "" || mediaTemplate == "" {
// Fallback: try regex extraction
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
@@ -934,11 +805,9 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
}
// Unescape HTML entities in URLs
initURL = strings.ReplaceAll(initURL, "&amp;", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&")
// Calculate segment count from timeline
segmentCount := 0
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
for i, seg := range segTemplate.Timeline.Segments {
@@ -947,10 +816,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
}
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
// If no segments found via XML, try regex
if segmentCount == 0 {
fmt.Println("[Tidal] No segments from XML, trying regex...")
// Match <S d="..." /> or <S d="..." r="..." />
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
@@ -967,7 +834,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
}
// Generate media URLs for each segment
for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
@@ -978,29 +844,43 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
// DownloadFile downloads a file from URL with progress tracking
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Handle manifest-based download (DASH/BTS)
ctx := context.Background()
if strings.HasPrefix(downloadURL, "MANIFEST:") {
// Initialize progress tracking for manifest downloads
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
}
// Initialize item progress for direct downloads
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
req, err := http.NewRequest("GET", downloadURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(t.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return err
}
defer resp.Body.Close()
@@ -1010,7 +890,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
expectedSize := resp.ContentLength
// Set total bytes if available
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -1020,26 +899,24 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return err
}
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
// Use item progress writer with buffered output
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
written, err = io.Copy(bufWriter, resp.Body)
}
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
@@ -1051,7 +928,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -1060,7 +936,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return nil
}
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
fmt.Println("[Tidal] Parsing manifest...")
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
if err != nil {
@@ -1074,12 +950,15 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
Timeout: 120 * time.Second,
}
// If we have a direct URL (BTS format), download directly with progress tracking
if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
// Note: Progress tracking is initialized by the caller (DownloadFile)
req, err := http.NewRequest("GET", directURL, nil)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", directURL, nil)
if err != nil {
GoLog("[Tidal] BTS request creation failed: %v\n", err)
return fmt.Errorf("failed to create request: %w", err)
@@ -1087,6 +966,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
resp, err := client.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] BTS download failed: %v\n", err)
return fmt.Errorf("failed to download file: %w", err)
}
@@ -1099,7 +981,6 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
expectedSize := resp.ContentLength
// Set total bytes for progress tracking
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
@@ -1109,7 +990,6 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
return fmt.Errorf("failed to create file: %w", err)
}
// Use item progress writer
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
@@ -1122,6 +1002,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if err != nil {
os.Remove(outputPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if closeErr != nil {
@@ -1129,7 +1012,6 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
@@ -1138,26 +1020,35 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
return nil
}
// DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
// On Android, we can't use ffmpeg, so we save as M4A directly
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
// We just update progress here based on segment count
out, err := os.Create(m4aPath)
if err != nil {
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
return fmt.Errorf("failed to create M4A file: %w", err)
}
// Download initialization segment
GoLog("[Tidal] Downloading init segment...\n")
resp, err := client.Get(initURL)
if isDownloadCancelled(itemID) {
out.Close()
os.Remove(m4aPath)
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
if err != nil {
out.Close()
os.Remove(m4aPath)
GoLog("[Tidal] Init segment request failed: %v\n", err)
return fmt.Errorf("failed to create init segment request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Init segment download failed: %v\n", err)
return fmt.Errorf("failed to download init segment: %w", err)
}
@@ -1173,27 +1064,44 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Init segment write failed: %v\n", err)
return fmt.Errorf("failed to write init segment: %w", err)
}
// Download media segments with progress
totalSegments := len(mediaURLs)
for i, mediaURL := range mediaURLs {
if isDownloadCancelled(itemID) {
out.Close()
os.Remove(m4aPath)
return ErrDownloadCancelled
}
if i%10 == 0 || i == totalSegments-1 {
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
}
// Update progress based on segment count
if itemID != "" {
progress := float64(i+1) / float64(totalSegments)
SetItemProgress(itemID, progress, 0, 0)
}
resp, err := client.Get(mediaURL)
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
if err != nil {
out.Close()
os.Remove(m4aPath)
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
}
resp, err := client.Do(req)
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
}
@@ -1209,6 +1117,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if err != nil {
out.Close()
os.Remove(m4aPath)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
}
@@ -1253,24 +1164,27 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return true
}
// Check first artist (before comma or feat)
spotifyFirst := strings.Split(normSpotify, ",")[0]
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
spotifyFirst = strings.TrimSpace(spotifyFirst)
// Split artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
spotifyArtists := splitArtists(normSpotify)
tidalArtists := splitArtists(normTidal)
tidalFirst := strings.Split(normTidal, ",")[0]
tidalFirst = strings.Split(tidalFirst, " feat")[0]
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
tidalFirst = strings.TrimSpace(tidalFirst)
if spotifyFirst == tidalFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
return true
// Check if ANY expected artist matches ANY found artist
for _, exp := range spotifyArtists {
for _, fnd := range tidalArtists {
if exp == fnd {
return true
}
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
// Check same words different order
if sameWordsUnordered(exp, fnd) {
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
}
}
}
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
@@ -1286,6 +1200,67 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return false
}
// splitArtists splits artist string by common separators
func splitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|")
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
normalized = strings.ReplaceAll(normalized, " ft ", "|")
normalized = strings.ReplaceAll(normalized, " & ", "|")
normalized = strings.ReplaceAll(normalized, " and ", "|")
normalized = strings.ReplaceAll(normalized, ", ", "|")
normalized = strings.ReplaceAll(normalized, " x ", "|")
parts := strings.Split(normalized, "|")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// sameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
// Must have same number of words
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
return false
}
// Sort and compare
sortedA := make([]string, len(wordsA))
sortedB := make([]string, len(wordsB))
copy(sortedA, wordsA)
copy(sortedB, wordsB)
// Simple bubble sort (usually just 2-3 words)
for i := 0; i < len(sortedA)-1; i++ {
for j := i + 1; j < len(sortedA); j++ {
if sortedA[i] > sortedA[j] {
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
}
if sortedB[i] > sortedB[j] {
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
}
}
}
for i := range sortedA {
if sortedA[i] != sortedB[i] {
return false
}
}
return true
}
// titlesMatch checks if track titles are similar enough
func titlesMatch(expectedTitle, foundTitle string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
@@ -1474,7 +1449,6 @@ func isLatinScript(s string) bool {
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader()
// Check for existing file first
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
@@ -1485,8 +1459,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
var track *TidalTrack
var err error
// STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority)
if req.TidalID != "" {
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
// Parse track ID (could be a number or extracted from URL)
var trackID int64
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackInfoByID(trackID)
if err != nil {
GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name)
}
}
}
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
@@ -1520,13 +1510,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
}
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
// Strategy 2: Try SongLink if we have Spotify ID
if track == nil && req.SpotifyID != "" {
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
var tidalURL string
var slErr error
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
if strings.HasPrefix(req.SpotifyID, "deezer:") {
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
@@ -1537,12 +1526,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
if slErr == nil && tidalURL != "" {
// Extract track ID and get track info
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
if idErr == nil {
track, err = downloader.GetTrackInfoByID(trackID)
if track != nil {
// Get artist name from track
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
@@ -1552,7 +1539,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
tidalArtist = strings.Join(artistNames, ", ")
}
// Verify artist matches (SongLink is already accurate, no title check needed)
if !artistsMatch(req.ArtistName, tidalArtist) {
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
@@ -1624,12 +1610,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
// Cache the track ID for future use
if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
}
// Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -1641,7 +1625,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
// Check if file already exists (both FLAC and M4A)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
@@ -1657,14 +1640,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
os.Remove(tmpPath)
}
// Determine quality to use (default to LOSSLESS if not specified)
quality := req.Quality
if quality == "" {
quality = "LOSSLESS"
}
GoLog("[Tidal] Using quality: %s\n", quality)
// Get download URL using parallel API requests
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
if err != nil {
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
@@ -1685,6 +1666,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}()
@@ -1698,6 +1680,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}())
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return TidalDownloadResult{}, ErrDownloadCancelled
}
GoLog("[Tidal] Download failed with error: %v\n", err)
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
@@ -1706,18 +1691,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Check if file was saved as M4A (DASH stream) instead of FLAC
// downloadFromManifest saves DASH streams as .m4a (m4aPath already defined above)
actualOutputPath := outputPath
if _, err := os.Stat(m4aPath); err == nil {
// File was saved as M4A, use that path
actualOutputPath = m4aPath
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
} else if _, err := os.Stat(outputPath); err != nil {
@@ -1738,7 +1718,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
ISRC: track.ISRC, // Use actual ISRC from Tidal
}
// Use cover data from parallel fetch
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
+75 -2
View File
@@ -120,6 +120,12 @@ import Gobackend // Import Go framework
let itemId = args["item_id"] as! String
GobackendClearItemProgress(itemId)
return nil
case "cancelDownload":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendCancelDownload(itemId)
return nil
case "setDownloadDirectory":
let args = call.arguments as! [String: Any]
@@ -155,7 +161,8 @@ import Gobackend // Import Go framework
let spotifyId = args["spotify_id"] as! String
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error)
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
if let error = error { throw error }
return response
@@ -165,7 +172,8 @@ import Gobackend // Import Go framework
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let filePath = args["file_path"] as? String ?? ""
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error }
return response
@@ -503,6 +511,30 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getAlbumWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let albumId = args["album_id"] as! String
let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error)
if let error = error { throw error }
return response
case "getPlaylistWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let playlistId = args["playlist_id"] as! String
let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error)
if let error = error { throw error }
return response
case "getArtistWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let artistId = args["artist_id"] as! String
let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error)
if let error = error { throw error }
return response
// Extension Post-Processing API
case "runPostProcessing":
let args = call.arguments as! [String: Any]
@@ -517,6 +549,47 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension Store
case "initExtensionStore":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
GobackendInitExtensionStoreJSON(cacheDir, &error)
if let error = error { throw error }
return nil
case "getStoreExtensions":
let args = call.arguments as! [String: Any]
let forceRefresh = args["force_refresh"] as? Bool ?? false
let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error)
if let error = error { throw error }
return response
case "searchStoreExtensions":
let args = call.arguments as! [String: Any]
let query = args["query"] as? String ?? ""
let category = args["category"] as? String ?? ""
let response = GobackendSearchStoreExtensionsJSON(query, category, &error)
if let error = error { throw error }
return response
case "getStoreCategories":
let response = GobackendGetStoreCategoriesJSON(&error)
if let error = error { throw error }
return response
case "downloadStoreExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let destDir = args["dest_dir"] as! String
let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error)
if let error = error { throw error }
return response
case "clearStoreCache":
GobackendClearStoreCacheJSON(&error)
if let error = error { throw error }
return nil
default:
throw NSError(
domain: "SpotiFLAC",
+17
View File
@@ -4,6 +4,23 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>de</string>
<string>es</string>
<string>fr</string>
<string>hi</string>
<string>id</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pt</string>
<string>ru</string>
<string>zh</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
</array>
<key>CFBundleDisplayName</key>
<string>SpotiFLAC</string>
<key>CFBundleExecutable</key>
+6
View File
@@ -0,0 +1,6 @@
arb-dir: lib/l10n/arb
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n
nullable-getter: false
+16 -1
View File
@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
return GoRouter(
@@ -31,6 +32,12 @@ class SpotiFLACApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(_routerProvider);
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
Locale? locale;
if (localeString != 'system') {
locale = Locale(localeString);
}
return DynamicColorWrapper(
builder: (lightTheme, darkTheme, themeMode) {
@@ -43,6 +50,14 @@ class SpotiFLACApp extends ConsumerWidget {
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
routerConfig: router,
locale: locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
);
},
);
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.0.0-alpha.4';
static const String buildNumber = '53';
static const String version = '3.1.1';
static const String buildNumber = '60';
static const String fullVersion = '$version+$buildNumber';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+681
View File
@@ -0,0 +1,681 @@
{
"@@locale": "id",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"appDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"navHome": "Beranda",
"navHistory": "Riwayat",
"navSettings": "Pengaturan",
"navStore": "Toko",
"homeTitle": "Beranda",
"homeSearchHint": "Tempel URL Spotify atau cari...",
"homeSearchHintExtension": "Cari dengan {extensionName}...",
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
"homeSupports": "Mendukung: URL Track, Album, Playlist, Artis",
"homeRecent": "Terbaru",
"historyTitle": "Riwayat",
"historyDownloading": "Mengunduh ({count})",
"historyDownloaded": "Terunduh",
"historyFilterAll": "Semua",
"historyFilterAlbums": "Album",
"historyFilterSingles": "Single",
"historyTracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} album}}",
"historyNoDownloads": "Tidak ada riwayat unduhan",
"historyNoDownloadsSubtitle": "Lagu yang diunduh akan muncul di sini",
"historyNoAlbums": "Tidak ada unduhan album",
"historyNoAlbumsSubtitle": "Unduh beberapa lagu dari album untuk melihatnya di sini",
"historyNoSingles": "Tidak ada unduhan single",
"historyNoSinglesSubtitle": "Unduhan lagu satuan akan muncul di sini",
"settingsTitle": "Pengaturan",
"settingsDownload": "Unduhan",
"settingsAppearance": "Tampilan",
"settingsOptions": "Opsi",
"settingsExtensions": "Ekstensi",
"settingsAbout": "Tentang",
"downloadTitle": "Unduhan",
"downloadLocation": "Lokasi Unduhan",
"downloadLocationSubtitle": "Pilih tempat menyimpan file",
"downloadLocationDefault": "Lokasi default",
"downloadDefaultService": "Layanan Default",
"downloadDefaultServiceSubtitle": "Layanan yang digunakan untuk unduhan",
"downloadDefaultQuality": "Kualitas Default",
"downloadAskQuality": "Tanya Kualitas Sebelum Unduh",
"downloadAskQualitySubtitle": "Tampilkan pemilih kualitas untuk setiap unduhan",
"downloadFilenameFormat": "Format Nama File",
"downloadFolderOrganization": "Organisasi Folder",
"downloadSeparateSingles": "Pisahkan Single",
"downloadSeparateSinglesSubtitle": "Letakkan lagu satuan di folder terpisah",
"qualityBest": "Terbaik",
"qualityFlac": "FLAC",
"quality320": "320 kbps",
"quality128": "128 kbps",
"appearanceTitle": "Tampilan",
"appearanceTheme": "Tema",
"appearanceThemeSystem": "Sistem",
"appearanceThemeLight": "Terang",
"appearanceThemeDark": "Gelap",
"appearanceDynamicColor": "Warna Dinamis",
"appearanceDynamicColorSubtitle": "Gunakan warna dari wallpaper Anda",
"appearanceAccentColor": "Warna Aksen",
"appearanceHistoryView": "Tampilan Riwayat",
"appearanceHistoryViewList": "Daftar",
"appearanceHistoryViewGrid": "Grid",
"optionsTitle": "Opsi",
"optionsSearchSource": "Sumber Pencarian",
"optionsPrimaryProvider": "Provider Utama",
"optionsPrimaryProviderSubtitle": "Layanan yang digunakan saat mencari berdasarkan nama lagu.",
"optionsUsingExtension": "Menggunakan ekstensi: {extensionName}",
"optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal",
"optionsUseExtensionProviders": "Gunakan Provider Ekstensi",
"optionsUseExtensionProvidersOn": "Ekstensi akan dicoba terlebih dahulu",
"optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan",
"optionsEmbedLyrics": "Sematkan Lirik",
"optionsEmbedLyricsSubtitle": "Sematkan lirik sinkron ke file FLAC",
"optionsMaxQualityCover": "Cover Kualitas Maksimal",
"optionsMaxQualityCoverSubtitle": "Unduh cover art resolusi tertinggi",
"optionsConcurrentDownloads": "Unduhan Bersamaan",
"optionsConcurrentSequential": "Berurutan (1 per waktu)",
"optionsConcurrentParallel": "{count} unduhan paralel",
"optionsConcurrentWarning": "Unduhan paralel dapat memicu pembatasan rate",
"optionsExtensionStore": "Toko Ekstensi",
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
"optionsCheckUpdates": "Periksa Pembaruan",
"optionsCheckUpdatesSubtitle": "Beritahu saat versi baru tersedia",
"optionsUpdateChannel": "Saluran Pembaruan",
"optionsUpdateChannelStable": "Hanya rilis stabil",
"optionsUpdateChannelPreview": "Dapatkan rilis preview",
"optionsUpdateChannelWarning": "Preview mungkin mengandung bug atau fitur belum lengkap",
"optionsClearHistory": "Hapus Riwayat Unduhan",
"optionsClearHistorySubtitle": "Hapus semua lagu dari riwayat",
"optionsDetailedLogging": "Log Detail",
"optionsDetailedLoggingOn": "Log detail sedang direkam",
"optionsDetailedLoggingOff": "Aktifkan untuk laporan bug",
"optionsSpotifyCredentials": "Kredensial Spotify",
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsRequired": "Diperlukan - ketuk untuk mengatur",
"optionsSpotifyWarning": "Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com",
"extensionsTitle": "Ekstensi",
"extensionsInstalled": "Ekstensi Terpasang",
"extensionsNone": "Tidak ada ekstensi terpasang",
"extensionsNoneSubtitle": "Pasang ekstensi dari tab Toko",
"extensionsEnabled": "Aktif",
"extensionsDisabled": "Nonaktif",
"extensionsVersion": "Versi {version}",
"extensionsAuthor": "oleh {author}",
"extensionsUninstall": "Copot",
"extensionsSetAsSearch": "Jadikan Provider Pencarian",
"storeTitle": "Toko Ekstensi",
"storeSearch": "Cari ekstensi...",
"storeInstall": "Pasang",
"storeInstalled": "Terpasang",
"storeUpdate": "Perbarui",
"aboutTitle": "Tentang",
"aboutContributors": "Kontributor",
"aboutMobileDeveloper": "Pengembang versi mobile",
"aboutOriginalCreator": "Pencipta SpotiFLAC asli",
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kami yang indah!",
"aboutSpecialThanks": "Terima Kasih Khusus",
"aboutLinks": "Tautan",
"aboutMobileSource": "Kode sumber mobile",
"aboutPCSource": "Kode sumber PC",
"aboutReportIssue": "Laporkan masalah",
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
"aboutFeatureRequest": "Permintaan fitur",
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
"aboutSupport": "Dukungan",
"aboutBuyMeCoffee": "Traktir saya kopi",
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
"aboutApp": "Aplikasi",
"aboutVersion": "Versi",
"albumTitle": "Album",
"albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"albumDownloadAll": "Unduh Semua",
"albumDownloadRemaining": "Unduh Sisanya",
"playlistTitle": "Playlist",
"artistTitle": "Artis",
"artistAlbums": "Album",
"artistSingles": "Single & EP",
"trackMetadataTitle": "Info Lagu",
"trackMetadataArtist": "Artis",
"trackMetadataAlbum": "Album",
"trackMetadataDuration": "Durasi",
"trackMetadataQuality": "Kualitas",
"trackMetadataPath": "Lokasi File",
"trackMetadataDownloadedAt": "Diunduh",
"trackMetadataService": "Layanan",
"trackMetadataPlay": "Putar",
"trackMetadataShare": "Bagikan",
"trackMetadataDelete": "Hapus",
"trackMetadataRedownload": "Unduh ulang",
"trackMetadataOpenFolder": "Buka Folder",
"setupTitle": "Selamat Datang di SpotiFLAC",
"setupSubtitle": "Mari mulai pengaturan",
"setupStoragePermission": "Izin Penyimpanan",
"setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan",
"setupStoragePermissionGranted": "Izin diberikan",
"setupStoragePermissionDenied": "Izin ditolak",
"setupGrantPermission": "Berikan Izin",
"setupDownloadLocation": "Lokasi Unduhan",
"setupChooseFolder": "Pilih Folder",
"setupContinue": "Lanjutkan",
"setupSkip": "Lewati untuk sekarang",
"dialogCancel": "Batal",
"dialogOk": "OK",
"dialogSave": "Simpan",
"dialogDelete": "Hapus",
"dialogRetry": "Coba Lagi",
"dialogClose": "Tutup",
"dialogYes": "Ya",
"dialogNo": "Tidak",
"dialogClear": "Hapus",
"dialogConfirm": "Konfirmasi",
"dialogDone": "Selesai",
"dialogClearHistoryTitle": "Hapus Riwayat",
"dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.",
"dialogDeleteSelectedTitle": "Hapus yang Dipilih",
"dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.",
"dialogImportPlaylistTitle": "Impor Playlist",
"dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?",
"snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian",
"snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian",
"snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh",
"snackbarHistoryCleared": "Riwayat dihapus",
"snackbarCredentialsSaved": "Kredensial disimpan",
"snackbarCredentialsCleared": "Kredensial dihapus",
"snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}",
"snackbarCannotOpenFile": "Tidak dapat membuka file: {error}",
"snackbarFillAllFields": "Harap isi semua field",
"snackbarViewQueue": "Lihat Antrian",
"errorRateLimited": "Dibatasi",
"errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.",
"errorFailedToLoad": "Gagal memuat {item}",
"errorNoTracksFound": "Tidak ada lagu ditemukan",
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
"statusQueued": "Mengantri",
"statusDownloading": "Mengunduh",
"statusFinalizing": "Menyelesaikan",
"statusCompleted": "Selesai",
"statusFailed": "Gagal",
"statusSkipped": "Dilewati",
"statusPaused": "Dijeda",
"actionPause": "Jeda",
"actionResume": "Lanjutkan",
"actionCancel": "Batal",
"actionStop": "Hentikan",
"actionSelect": "Pilih",
"actionSelectAll": "Pilih Semua",
"actionDeselect": "Batal Pilih",
"actionPaste": "Tempel",
"actionImportCsv": "Impor CSV",
"actionRemoveCredentials": "Hapus Kredensial",
"actionSaveCredentials": "Simpan Kredensial",
"selectionSelected": "{count} dipilih",
"selectionAllSelected": "Semua lagu dipilih",
"selectionTapToSelect": "Ketuk lagu untuk memilih",
"selectionDeleteTracks": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"selectionSelectToDelete": "Pilih lagu untuk dihapus",
"progressFetchingMetadata": "Mengambil metadata... {current}/{total}",
"progressReadingCsv": "Membaca CSV...",
"searchSongs": "Lagu",
"searchArtists": "Artis",
"searchAlbums": "Album",
"searchPlaylists": "Playlist",
"tooltipPlay": "Putar",
"tooltipCancel": "Batal",
"tooltipStop": "Hentikan",
"tooltipRetry": "Coba Lagi",
"tooltipRemove": "Hapus",
"tooltipClear": "Hapus",
"tooltipPaste": "Tempel",
"filenameFormat": "Format Nama File",
"filenameFormatPreview": "Pratinjau: {preview}",
"folderOrganization": "Organisasi Folder",
"folderOrganizationNone": "Tanpa organisasi",
"folderOrganizationByArtist": "Berdasarkan Artis",
"folderOrganizationByAlbum": "Berdasarkan Album",
"folderOrganizationByArtistAlbum": "Artis/Album",
"updateAvailable": "Pembaruan Tersedia",
"updateNewVersion": "Versi {version} tersedia",
"updateDownload": "Unduh",
"updateLater": "Nanti",
"updateChangelog": "Log Perubahan",
"providerPriority": "Prioritas Provider",
"providerPrioritySubtitle": "Seret untuk mengatur ulang provider unduhan",
"metadataProviderPriority": "Prioritas Provider Metadata",
"metadataProviderPrioritySubtitle": "Urutan yang digunakan saat mengambil metadata lagu",
"logTitle": "Log",
"logCopy": "Salin Log",
"logClear": "Hapus Log",
"logShare": "Bagikan Log",
"logEmpty": "Belum ada log",
"logCopied": "Log disalin ke clipboard",
"credentialsTitle": "Kredensial Spotify",
"credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.",
"credentialsClientId": "Client ID",
"credentialsClientIdHint": "Tempel Client ID",
"credentialsClientSecret": "Client Secret",
"credentialsClientSecretHint": "Tempel Client Secret",
"channelStable": "Stabil",
"channelPreview": "Preview",
"sectionSearchSource": "Sumber Pencarian",
"sectionDownload": "Unduhan",
"sectionPerformance": "Performa",
"sectionApp": "Aplikasi",
"sectionData": "Data",
"sectionDebug": "Debug",
"sectionService": "Layanan",
"sectionAudioQuality": "Kualitas Audio",
"sectionFileSettings": "Pengaturan File",
"sectionColor": "Warna",
"sectionTheme": "Tema",
"sectionLayout": "Tata Letak",
"sectionLanguage": "Bahasa",
"appearanceLanguage": "Bahasa Aplikasi",
"appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan",
"languageSystem": "Bawaan Sistem",
"languageEnglish": "English",
"languageIndonesian": "Bahasa Indonesia",
"settingsAppearanceSubtitle": "Tema, warna, tampilan",
"settingsDownloadSubtitle": "Layanan, kualitas, format nama file",
"settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan",
"settingsExtensionsSubtitle": "Kelola provider unduhan",
"settingsLogsSubtitle": "Lihat log aplikasi untuk debugging",
"loadingSharedLink": "Memuat link yang dibagikan...",
"pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar",
"artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}",
"artistCompilations": "Kompilasi",
"artistPopular": "Populer",
"artistMonthlyListeners": "{count} pendengar bulanan",
"tracksHeader": "Lagu",
"downloadAllCount": "Unduh Semua ({count})",
"tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"setupStorageAccessRequired": "Akses Penyimpanan Diperlukan",
"setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.",
"setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.",
"setupOpenSettings": "Buka Pengaturan",
"setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.",
"setupPermissionRequired": "Izin {permissionType} Diperlukan",
"setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.",
"setupSelectDownloadFolder": "Pilih Folder Unduhan",
"setupUseDefaultFolder": "Gunakan Folder Default?",
"setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?",
"setupUseDefault": "Gunakan Default",
"setupDownloadLocationTitle": "Lokasi Unduhan",
"setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.",
"setupAppDocumentsFolder": "Folder Documents Aplikasi",
"setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files",
"setupChooseFromFiles": "Pilih dari Files",
"setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya",
"setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.",
"setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC",
"setupStepStorage": "Penyimpanan",
"setupStepNotification": "Notifikasi",
"setupStepFolder": "Folder",
"setupStepSpotify": "Spotify",
"setupStepPermission": "Izin",
"setupStorageGranted": "Izin Penyimpanan Diberikan!",
"setupStorageRequired": "Izin Penyimpanan Diperlukan",
"setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.",
"setupNotificationGranted": "Izin Notifikasi Diberikan!",
"setupNotificationEnable": "Aktifkan Notifikasi",
"setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.",
"setupFolderSelected": "Folder Unduhan Dipilih!",
"setupFolderChoose": "Pilih Folder Unduhan",
"setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.",
"setupChangeFolder": "Ubah Folder",
"setupSelectFolder": "Pilih Folder",
"setupSpotifyApiOptional": "Spotify API (Opsional)",
"setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.",
"setupUseSpotifyApi": "Gunakan Spotify API",
"setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah",
"setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)",
"setupEnterClientId": "Masukkan Spotify Client ID",
"setupEnterClientSecret": "Masukkan Spotify Client Secret",
"setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.",
"setupEnableNotifications": "Aktifkan Notifikasi",
"dialogImport": "Impor",
"dialogDiscard": "Buang",
"dialogRemove": "Hapus",
"dialogUninstall": "Copot",
"dialogDiscardChanges": "Buang Perubahan?",
"dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?",
"dialogDownloadFailed": "Unduhan Gagal",
"dialogTrackLabel": "Lagu:",
"dialogArtistLabel": "Artis:",
"dialogErrorLabel": "Error:",
"dialogClearAll": "Hapus Semua",
"dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?",
"dialogRemoveFromDevice": "Hapus dari perangkat?",
"dialogRemoveExtension": "Hapus Ekstensi",
"dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.",
"dialogUninstallExtension": "Copot Ekstensi?",
"dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?",
"snackbarFailedToLoad": "Gagal memuat: {error}",
"snackbarUrlCopied": "URL {platform} disalin ke clipboard",
"snackbarFileNotFound": "File tidak ditemukan",
"snackbarSelectExtFile": "Harap pilih file .spotiflac-ext",
"snackbarProviderPrioritySaved": "Prioritas provider disimpan",
"snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan",
"snackbarExtensionInstalled": "{extensionName} terpasang.",
"snackbarExtensionUpdated": "{extensionName} diperbarui.",
"snackbarFailedToInstall": "Gagal memasang ekstensi",
"snackbarFailedToUpdate": "Gagal memperbarui ekstensi",
"storeFilterAll": "Semua",
"storeFilterMetadata": "Metadata",
"storeFilterDownload": "Unduhan",
"storeFilterUtility": "Utilitas",
"storeFilterLyrics": "Lirik",
"storeFilterIntegration": "Integrasi",
"storeClearFilters": "Hapus filter",
"storeNoResults": "Tidak ada ekstensi ditemukan",
"extensionProviderPriority": "Prioritas Provider",
"extensionInstallButton": "Pasang Ekstensi",
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan",
"extensionAuthor": "Pembuat",
"extensionId": "ID",
"extensionError": "Error",
"extensionCapabilities": "Kemampuan",
"extensionMetadataProvider": "Provider Metadata",
"extensionDownloadProvider": "Provider Unduhan",
"extensionLyricsProvider": "Provider Lirik",
"extensionUrlHandler": "Penanganan URL",
"extensionQualityOptions": "Opsi Kualitas",
"extensionPostProcessingHooks": "Hook Pasca-Pemrosesan",
"extensionPermissions": "Izin",
"extensionSettings": "Pengaturan",
"extensionRemoveButton": "Hapus Ekstensi",
"extensionUpdated": "Diperbarui",
"extensionMinAppVersion": "Versi App Minimum",
"qualityFlacLossless": "FLAC Lossless",
"qualityFlacLosslessSubtitle": "16-bit / 44.1kHz",
"qualityHiResFlac": "Hi-Res FLAC",
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
"qualityHiResFlacMax": "Hi-Res FLAC Max",
"qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz",
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
"downloadDirectory": "Direktori Unduhan",
"downloadSeparateSinglesFolder": "Folder Singles Terpisah",
"downloadAlbumFolderStructure": "Struktur Folder Album",
"downloadSaveFormat": "Simpan Format",
"downloadSelectService": "Pilih Layanan",
"downloadSelectQuality": "Pilih Kualitas",
"downloadFrom": "Unduh Dari",
"downloadDefaultQualityLabel": "Kualitas Default",
"downloadBestAvailable": "Terbaik tersedia",
"folderNone": "Tidak ada",
"folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan",
"folderArtist": "Artis",
"folderArtistSubtitle": "Nama Artis/namafile",
"folderAlbum": "Album",
"folderAlbumSubtitle": "Nama Album/namafile",
"folderArtistAlbum": "Artis/Album",
"folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile",
"serviceTidal": "Tidal",
"serviceQobuz": "Qobuz",
"serviceAmazon": "Amazon",
"serviceDeezer": "Deezer",
"serviceSpotify": "Spotify",
"logSearchHint": "Cari log...",
"logFilterLevel": "Level",
"logFilterSection": "Filter",
"logShareLogs": "Bagikan log",
"logClearLogs": "Hapus log",
"logClearLogsTitle": "Hapus Log",
"logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?",
"logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI",
"logRateLimited": "DIBATASI",
"logNetworkError": "ERROR JARINGAN",
"logTrackNotFound": "LAGU TIDAK DITEMUKAN",
"appearanceAmoledDark": "AMOLED Gelap",
"appearanceAmoledDarkSubtitle": "Latar belakang hitam murni",
"appearanceChooseAccentColor": "Pilih Warna Aksen",
"appearanceChooseTheme": "Mode Tema",
"updateStartingDownload": "Memulai unduhan...",
"updateDownloadFailed": "Unduhan gagal",
"updateFailedMessage": "Gagal mengunduh pembaruan",
"updateNewVersionReady": "Versi baru sudah siap",
"updateCurrent": "Saat ini",
"updateNew": "Baru",
"updateDownloading": "Mengunduh...",
"updateWhatsNew": "Yang Baru",
"updateDownloadInstall": "Unduh & Pasang",
"updateDontRemind": "Jangan ingatkan",
"trackCopyFilePath": "Salin lokasi file",
"trackRemoveFromDevice": "Hapus dari perangkat",
"trackLoadLyrics": "Muat Lirik",
"dateToday": "Hari ini",
"dateYesterday": "Kemarin",
"dateDaysAgo": "{count} hari lalu",
"dateWeeksAgo": "{count} minggu lalu",
"dateMonthsAgo": "{count} bulan lalu",
"concurrentSequential": "Berurutan",
"concurrentParallel2": "2 Paralel",
"concurrentParallel3": "3 Paralel",
"filenameAvailablePlaceholders": "Placeholder yang tersedia:",
"filenameHint": "{artist} - {title}",
"tapToSeeError": "Ketuk untuk melihat detail error",
"setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.",
"setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.",
"setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.",
"setupSkipForNow": "Lewati untuk sekarang",
"setupBack": "Kembali",
"setupNext": "Lanjut",
"setupGetStarted": "Mulai",
"setupSkipAndStart": "Lewati & Mulai",
"setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.",
"setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com",
"trackMetadata": "Metadata",
"trackFileInfo": "Info File",
"trackLyrics": "Lirik",
"trackFileNotFound": "File tidak ditemukan",
"trackOpenInDeezer": "Buka di Deezer",
"trackOpenInSpotify": "Buka di Spotify",
"trackTrackName": "Nama lagu",
"trackArtist": "Artis",
"trackAlbumArtist": "Artis album",
"trackAlbum": "Album",
"trackTrackNumber": "Nomor lagu",
"trackDiscNumber": "Nomor disc",
"trackDuration": "Durasi",
"trackAudioQuality": "Kualitas audio",
"trackReleaseDate": "Tanggal rilis",
"trackDownloaded": "Diunduh",
"trackCopyLyrics": "Salin lirik",
"trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini",
"trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.",
"trackLyricsLoadFailed": "Gagal memuat lirik",
"trackCopiedToClipboard": "Disalin ke clipboard",
"trackDeleteConfirmTitle": "Hapus dari perangkat?",
"trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.",
"trackCannotOpen": "Tidak dapat membuka: {message}",
"logFilterBySeverity": "Filter log berdasarkan tingkat keparahan",
"logNoLogsYet": "Belum ada log",
"logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi",
"logIssueSummary": "Ringkasan Masalah",
"logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan",
"logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8",
"logRateLimitedDescription": "Terlalu banyak permintaan ke layanan",
"logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi",
"logNetworkErrorDescription": "Masalah koneksi terdeteksi",
"logNetworkErrorSuggestion": "Periksa koneksi internet Anda",
"logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan",
"logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless",
"logTotalErrors": "Total error: {count}",
"logAffected": "Terpengaruh: {domains}",
"logEntriesFiltered": "Entri ({count} difilter)",
"logEntries": "Entri ({count})",
"extensionsProviderPrioritySection": "Prioritas Provider",
"extensionsInstalledSection": "Ekstensi Terpasang",
"extensionsNoExtensions": "Tidak ada ekstensi terpasang",
"extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru",
"extensionsInstallButton": "Pasang Ekstensi",
"extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.",
"extensionsInstalledSuccess": "Ekstensi berhasil dipasang",
"extensionsDownloadPriority": "Prioritas Unduhan",
"extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan",
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
"extensionsMetadataPriority": "Prioritas Metadata",
"extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata",
"extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata",
"extensionsSearchProvider": "Provider Pencarian",
"extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom",
"extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu",
"extensionsCustomSearch": "Pencarian kustom",
"extensionsErrorLoading": "Error memuat ekstensi",
"extensionCustomTrackMatching": "Pencocokan Lagu Kustom",
"extensionPostProcessing": "Pasca-Pemrosesan",
"extensionHooksAvailable": "{count} hook tersedia",
"extensionPatternsCount": "{count} pola",
"extensionStrategy": "Strategi: {strategy}",
"aboutDoubleDouble": "DoubleDouble",
"aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!",
"aboutDabMusic": "DAB Music",
"aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!",
"queueTitle": "Antrian Unduhan",
"queueClearAll": "Hapus Semua",
"queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?",
"albumFolderArtistAlbum": "Artis / Album",
"albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/",
"albumFolderArtistYearAlbum": "Artis / [Tahun] Album",
"albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/",
"albumFolderAlbumOnly": "Album Saja",
"albumFolderAlbumOnlySubtitle": "Albums/Nama Album/",
"albumFolderYearAlbum": "[Tahun] Album",
"albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/",
"downloadedAlbumDeleteSelected": "Hapus yang Dipilih",
"downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.",
"utilityFunctions": "Fungsi Utilitas",
"aboutMobileDeveloper": "Pengembang versi mobile",
"aboutOriginalCreator": "Pembuat SpotiFLAC asli",
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!",
"aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!",
"aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!",
"aboutMobileSource": "Kode sumber mobile",
"aboutPCSource": "Kode sumber PC",
"aboutReportIssue": "Laporkan masalah",
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
"aboutFeatureRequest": "Permintaan fitur",
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
"aboutBuyMeCoffee": "Belikan saya kopi",
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
"aboutVersion": "Versi",
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"providerPriorityTitle": "Prioritas Provider",
"providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.",
"providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.",
"providerBuiltIn": "Bawaan",
"providerExtension": "Ekstensi",
"metadataProviderPriorityTitle": "Prioritas Metadata",
"metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.",
"metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.",
"metadataNoRateLimits": "Tidak ada batas rate",
"metadataMayRateLimit": "Mungkin dibatasi rate",
"queueEmpty": "Tidak ada unduhan dalam antrian",
"queueEmptySubtitle": "Tambahkan lagu dari layar beranda",
"queueClearCompleted": "Hapus yang selesai",
"queueDownloadFailed": "Unduhan Gagal",
"queueTrackLabel": "Lagu:",
"queueArtistLabel": "Artis:",
"queueErrorLabel": "Error:",
"queueUnknownError": "Error tidak diketahui",
"downloadedAlbumTracksHeader": "Lagu",
"downloadedAlbumDownloadedCount": "{count} diunduh",
"downloadedAlbumSelectedCount": "{count} dipilih",
"downloadedAlbumAllSelected": "Semua lagu dipilih",
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
"folderOrganizationNone": "Tidak ada",
"folderOrganizationNoneSubtitle": "Semua file di folder unduhan",
"folderOrganizationByArtist": "Berdasarkan Artis",
"folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis",
"folderOrganizationByAlbum": "Berdasarkan Album",
"folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album",
"folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album",
"folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album",
"recentTypeArtist": "Artis",
"recentTypeAlbum": "Album",
"recentTypeSong": "Lagu",
"recentTypePlaylist": "Playlist",
"recentPlaylistInfo": "Playlist: {name}",
"errorGeneric": "Error: {message}"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
export 'package:spotiflac_android/l10n/app_localizations.dart';
/// Extension to easily access AppLocalizations from BuildContext
extension AppLocalizationsX on BuildContext {
/// Get the AppLocalizations instance
/// Usage: context.l10n.navHome
AppLocalizations get l10n => AppLocalizations.of(this);
}
+48
View File
@@ -0,0 +1,48 @@
// GENERATED FILE - DO NOT EDIT
// Generated by: dart run tool/check_translations.dart 0
// Only languages with >= 0% translation completion are included.
// Translation is measured by comparing VALUES (not just key existence).
//
// To regenerate, run: dart run tool/check_translations.dart 0
import 'package:flutter/widgets.dart';
/// Minimum translation completion threshold used to filter languages.
const int translationThreshold = 0;
/// List of locales that meet the translation threshold.
/// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('ru'),
Locale('id'),
Locale('ja'),
Locale('de'),
Locale('es'),
Locale('fr'),
Locale('hi'),
Locale('ko'),
Locale('nl'),
Locale('pt'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'ru',
'id',
'ja',
'de',
'es',
'fr',
'hi',
'ko',
'nl',
'pt',
'zh',
'zh_CN',
'zh_TW',
};
-5
View File
@@ -11,10 +11,8 @@ import 'package:spotiflac_android/services/share_intent_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize notification service
await NotificationService().initialize();
// Initialize share intent service
await ShareIntentService().initialize();
runApp(
@@ -48,11 +46,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
// Create directories if needed
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
// Initialize extension system
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
} catch (e) {
debugPrint('Failed to initialize extensions: $e');
@@ -61,7 +57,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override
Widget build(BuildContext context) {
// Eagerly initialize download history provider to load from storage
ref.watch(downloadHistoryProvider);
return widget.child;
}
+10 -1
View File
@@ -28,7 +28,9 @@ class AppSettings {
final bool useExtensionProviders; // Use extension providers for downloads when available
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
final bool separateSingles; // Separate singles/EPs into their own folder
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
final bool showExtensionStore; // Show Extension Store tab in navigation
final String locale; // App language: 'system', 'en', 'id', etc.
const AppSettings({
this.defaultService = 'tidal',
@@ -55,7 +57,9 @@ class AppSettings {
this.useExtensionProviders = true, // Default: use extensions when available
this.searchProvider, // Default: null (use Deezer/Spotify)
this.separateSingles = false, // Default: disabled
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
this.showExtensionStore = true, // Default: show store
this.locale = 'system', // Default: follow system language
});
AppSettings copyWith({
@@ -82,8 +86,11 @@ class AppSettings {
bool? enableLogging,
bool? useExtensionProviders,
String? searchProvider,
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -108,9 +115,11 @@ class AppSettings {
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
searchProvider: searchProvider ?? this.searchProvider,
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
);
}
+5
View File
@@ -32,7 +32,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -61,5 +64,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider,
'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
};
+14
View File
@@ -20,6 +20,7 @@ class Track {
final ServiceAvailability? availability;
final String? source; // Extension ID that provided this track (null for built-in sources)
final String? albumType; // album, single, ep, compilation (from metadata API)
final String? itemType; // track, album, playlist - for extension search results
const Track({
required this.id,
@@ -37,10 +38,23 @@ class Track {
this.availability,
this.source,
this.albumType,
this.itemType,
});
/// Check if this track is a single (based on album_type metadata)
bool get isSingle => albumType == 'single' || albumType == 'ep';
/// Check if this is an album item (not a track)
bool get isAlbumItem => itemType == 'album';
/// Check if this is a playlist item (not a track)
bool get isPlaylistItem => itemType == 'playlist';
/// Check if this is an artist item (not a track)
bool get isArtistItem => itemType == 'artist';
/// Check if this is a collection (album, playlist, or artist)
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
+2
View File
@@ -26,6 +26,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
),
source: json['source'] as String?,
albumType: json['albumType'] as String?,
itemType: json['itemType'] as String?,
);
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
@@ -44,6 +45,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'availability': instance.availability,
'source': instance.source,
'albumType': instance.albumType,
'itemType': instance.itemType,
};
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
File diff suppressed because it is too large Load Diff
-7
View File
@@ -175,12 +175,10 @@ class SearchBehavior {
/// Get thumbnail size based on configuration
/// Returns (width, height) tuple
(double, double) getThumbnailSize({double defaultSize = 56}) {
// If custom dimensions specified, use them
if (thumbnailWidth != null && thumbnailHeight != null) {
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
}
// Otherwise use ratio presets
switch (thumbnailRatio) {
case 'wide': // 16:9 - YouTube style
return (defaultSize * 16 / 9, defaultSize);
@@ -558,10 +556,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
_log.d('Set extension $extensionId enabled: $enabled');
// Get extension info before updating state
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
// Update local state
final extensions = state.extensions.map((e) {
if (e.id == extensionId) {
return e.copyWith(enabled: enabled);
@@ -571,18 +567,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(extensions: extensions);
// If disabling an extension, reset related settings
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
// If this extension was the search provider, clear it and reset to Deezer
if (settings.searchProvider == extensionId) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
}
// If this extension was the default download service, reset to Tidal
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
ref.read(settingsProvider.notifier).setDefaultService('tidal');
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
+242
View File
@@ -0,0 +1,242 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _recentAccessKey = 'recent_access_history';
const _maxRecentItems = 20;
/// Types of items that can be accessed
enum RecentAccessType {
artist,
album,
track,
playlist,
}
/// Represents a recently accessed item
class RecentAccessItem {
final String id;
final String name;
final String? subtitle; // Artist name for tracks/albums, null for artists
final String? imageUrl;
final RecentAccessType type;
final DateTime accessedAt;
final String? providerId; // Extension ID or 'deezer' for built-in
const RecentAccessItem({
required this.id,
required this.name,
this.subtitle,
this.imageUrl,
required this.type,
required this.accessedAt,
this.providerId,
});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'subtitle': subtitle,
'imageUrl': imageUrl,
'type': type.name,
'accessedAt': accessedAt.toIso8601String(),
'providerId': providerId,
};
factory RecentAccessItem.fromJson(Map<String, dynamic> json) {
return RecentAccessItem(
id: json['id'] as String,
name: json['name'] as String,
subtitle: json['subtitle'] as String?,
imageUrl: json['imageUrl'] as String?,
type: RecentAccessType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => RecentAccessType.track,
),
accessedAt: DateTime.parse(json['accessedAt'] as String),
providerId: json['providerId'] as String?,
);
}
/// Create a unique key for deduplication
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RecentAccessItem &&
runtimeType == other.runtimeType &&
uniqueKey == other.uniqueKey;
@override
int get hashCode => uniqueKey.hashCode;
}
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final bool isLoaded;
const RecentAccessState({
this.items = const [],
this.isLoaded = false,
});
RecentAccessState copyWith({
List<RecentAccessItem>? items,
bool? isLoaded,
}) {
return RecentAccessState(
items: items ?? this.items,
isLoaded: isLoaded ?? this.isLoaded,
);
}
}
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> {
@override
RecentAccessState build() {
_loadHistory();
return const RecentAccessState();
}
Future<void> _loadHistory() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_recentAccessKey);
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
final items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(items: items, isLoaded: true);
} catch (e) {
state = state.copyWith(isLoaded: true);
}
} else {
state = state.copyWith(isLoaded: true);
}
}
Future<void> _saveHistory() async {
final prefs = await SharedPreferences.getInstance();
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json);
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
required String name,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
imageUrl: imageUrl,
type: RecentAccessType.artist,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
/// Record an access to an album
void recordAlbumAccess({
required String id,
required String name,
String? artistName,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.album,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
/// Record an access to a track
void recordTrackAccess({
required String id,
required String name,
String? artistName,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.track,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
/// Record an access to a playlist
void recordPlaylistAccess({
required String id,
required String name,
String? ownerName,
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: ownerName,
imageUrl: imageUrl,
type: RecentAccessType.playlist,
accessedAt: DateTime.now(),
providerId: providerId,
));
}
void _recordAccess(RecentAccessItem item) {
// ignore: avoid_print
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
updatedItems.insert(0, item);
if (updatedItems.length > _maxRecentItems) {
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
}
state = state.copyWith(items: updatedItems);
_saveHistory();
// ignore: avoid_print
print('[RecentAccess] Total items now: ${updatedItems.length}');
}
/// Remove a specific item from history
void removeItem(RecentAccessItem item) {
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
state = state.copyWith(items: updatedItems);
_saveHistory();
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
_saveHistory();
}
}
/// Provider instance
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new,
);
+15 -13
View File
@@ -22,13 +22,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
// Run migrations if needed
await _runMigrations(prefs);
// Apply Spotify credentials to Go backend on load
_applySpotifyCredentials();
// Sync logging state
LogBuffer.loggingEnabled = state.enableLogging;
}
}
@@ -38,16 +35,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) {
// Migration 1: Set metadataSource to 'deezer' for existing users
// Only apply if user hasn't enabled custom Spotify credentials
// (users with custom credentials likely prefer Spotify)
if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer');
await _saveSettings();
}
}
// Save current migration version
if (lastMigration < _currentMigrationVersion) {
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
}
@@ -60,7 +53,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
/// Apply current Spotify credentials to Go backend
Future<void> _applySpotifyCredentials() async {
// Only apply if both fields are set
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
@@ -68,8 +60,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
state.spotifyClientSecret,
);
}
// Note: If credentials are empty, Spotify API will return error
// User should use Deezer as metadata source instead
}
void setDefaultService(String service) {
@@ -113,7 +103,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setConcurrentDownloads(int count) {
// Clamp between 1 and 3
final clamped = count.clamp(1, 3);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
@@ -196,14 +185,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setSearchProvider(String? provider) {
state = state.copyWith(searchProvider: provider);
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearSearchProvider: true);
} else {
state = state.copyWith(searchProvider: provider);
}
_saveSettings();
}
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
// Sync logging state to LogBuffer
LogBuffer.loggingEnabled = enabled;
}
@@ -217,10 +209,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setAlbumFolderStructure(String structure) {
state = state.copyWith(albumFolderStructure: structure);
_saveSettings();
}
void setShowExtensionStore(bool enabled) {
state = state.copyWith(showExtensionStore: enabled);
_saveSettings();
}
void setLocale(String locale) {
state = state.copyWith(locale: locale);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+30
View File
@@ -1,10 +1,29 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/constants/app_info.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('StoreProvider');
/// 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 maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
for (var i = 0; i < maxLen; i++) {
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
if (n1 < n2) return -1;
if (n1 > n2) return 1;
}
return 0;
}
/// Extension categories
class StoreCategory {
static const String metadata = 'metadata';
@@ -91,6 +110,12 @@ class StoreExtension {
hasUpdate: json['has_update'] as bool? ?? false,
);
}
/// Check if this extension requires a higher app version than current
bool get requiresNewerApp {
if (minAppVersion == null || minAppVersion!.isEmpty) return false;
return compareVersions(minAppVersion!, AppInfo.version) > 0;
}
}
/// State for extension store
@@ -161,6 +186,11 @@ class StoreState {
return result;
}
/// Count of extensions with updates available
int get updatesAvailableCount {
return extensions.where((e) => e.hasUpdate).length;
}
}
/// Provider for managing extension store
+67 -38
View File
@@ -17,9 +17,13 @@ class TrackState {
final String? artistId;
final String? artistName;
final String? coverUrl;
final String? headerImageUrl; // Artist header image for background
final int? monthlyListeners; // Artist monthly listeners
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode
final String? searchExtensionId; // Extension ID used for current search results
const TrackState({
@@ -32,9 +36,13 @@ class TrackState {
this.artistId,
this.artistName,
this.coverUrl,
this.headerImageUrl,
this.monthlyListeners,
this.artistAlbums,
this.artistTopTracks,
this.searchArtists,
this.hasSearchText = false,
this.isShowingRecentAccess = false,
this.searchExtensionId,
});
@@ -50,9 +58,13 @@ class TrackState {
String? artistId,
String? artistName,
String? coverUrl,
String? headerImageUrl,
int? monthlyListeners,
List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks,
List<SearchArtist>? searchArtists,
bool? hasSearchText,
bool? isShowingRecentAccess,
String? searchExtensionId,
}) {
return TrackState(
@@ -65,9 +77,13 @@ class TrackState {
artistId: artistId ?? this.artistId,
artistName: artistName ?? this.artistName,
coverUrl: coverUrl ?? this.coverUrl,
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
searchArtists: searchArtists ?? this.searchArtists,
hasSearchText: hasSearchText ?? this.hasSearchText,
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
searchExtensionId: searchExtensionId,
);
}
@@ -82,6 +98,7 @@ class ArtistAlbum {
final String? coverUrl;
final String albumType; // album, single, compilation
final String artists;
final String? providerId; // Extension ID if from extension
const ArtistAlbum({
required this.id,
@@ -91,6 +108,7 @@ class ArtistAlbum {
this.coverUrl,
required this.albumType,
required this.artists,
this.providerId,
});
}
@@ -124,14 +142,11 @@ class TrackNotifier extends Notifier<TrackState> {
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during fetch
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// First, check if any extension can handle this URL
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -169,13 +184,20 @@ class TrackNotifier extends Notifier<TrackState> {
final artistData = result['artist'] as Map<String, dynamic>;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistData['id'] as String?,
artistName: artistData['name'] as String?,
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
headerImageUrl: artistData['header_image'] as String?,
monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
searchExtensionId: extensionId,
);
return;
@@ -183,13 +205,11 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// No extension handler found, try Spotify URL parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
final type = parsed['type'] as String;
// Use the new fallback-enabled method
Map<String, dynamic> metadata;
try {
@@ -199,7 +219,6 @@ class TrackNotifier extends Notifier<TrackState> {
// ignore: avoid_print
print('[FetchURL] Metadata fetch success');
} catch (e) {
// If fallback also fails, show error
// ignore: avoid_print
print('[FetchURL] Metadata fetch failed: $e');
rethrow;
@@ -226,7 +245,6 @@ class TrackNotifier extends Notifier<TrackState> {
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
// Pre-warm cache for album tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
@@ -239,7 +257,6 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
);
// Pre-warm cache for playlist tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
@@ -255,44 +272,44 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
} catch (e) {
if (!_isRequestValid(requestId)) return; // Request cancelled
// Preserve hasSearchText on error so user stays on search screen
if (!_isRequestValid(requestId)) return;
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
}
}
Future<void> search(String query, {String? metadataSource}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Check if extension providers should be used for search
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final hasActiveMetadataExtensions = extensionState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider,
);
final useExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions;
final searchProvider = settings.searchProvider;
final useExtensions =
settings.useExtensionProviders &&
hasActiveMetadataExtensions &&
searchProvider != null &&
searchProvider.isNotEmpty;
// Use Deezer or Spotify based on settings
final source = metadataSource ?? 'deezer';
_log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions');
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions',
);
Map<String, dynamic> results;
List<Track> extensionTracks = [];
// Try extension providers first if enabled
if (useExtensions) {
try {
_log.d('Calling extension search API...');
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
_log.i('Extensions returned ${extResults.length} tracks');
// Parse extension results
for (final t in extResults) {
try {
extensionTracks.add(_parseSearchTrack(t));
@@ -305,7 +322,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Also search with built-in providers
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
@@ -326,13 +342,10 @@ class TrackNotifier extends Notifier<TrackState> {
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
// Parse tracks with error handling per item
final tracks = <Track>[];
// Add extension tracks first (they have priority)
tracks.addAll(extensionTracks);
// Add built-in provider tracks, avoiding duplicates by ISRC
final existingIsrcs = extensionTracks
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
.map((t) => t.isrc!)
@@ -343,7 +356,6 @@ class TrackNotifier extends Notifier<TrackState> {
try {
if (t is Map<String, dynamic>) {
final track = _parseSearchTrack(t);
// Skip if we already have this track from extensions
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
continue;
}
@@ -356,7 +368,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Parse artists with error handling per item
final artists = <SearchArtist>[];
for (int i = 0; i < artistList.length; i++) {
final a = artistList[i];
@@ -388,10 +399,8 @@ class TrackNotifier extends Notifier<TrackState> {
/// Perform custom search using a specific extension
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
// Preserve hasSearchText during search
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
@@ -406,7 +415,6 @@ class TrackNotifier extends Notifier<TrackState> {
_log.i('Custom search returned ${results.length} tracks');
// Parse tracks with error handling per item, setting source to extension ID
final tracks = <Track>[];
for (int i = 0; i < results.length; i++) {
final t = results[i];
@@ -453,6 +461,8 @@ class TrackNotifier extends Notifier<TrackState> {
trackNumber: track.trackNumber,
discNumber: track.discNumber,
releaseDate: track.releaseDate,
albumType: track.albumType,
source: track.source,
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
qobuz: availability['qobuz'] as bool? ?? false,
@@ -467,7 +477,6 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (e) {
// Silently fail availability check
}
}
@@ -479,6 +488,28 @@ class TrackNotifier extends Notifier<TrackState> {
void setSearchText(bool hasText) {
state = state.copyWith(hasSearchText: hasText);
}
/// Set recent access mode state
void setShowingRecentAccess(bool showing) {
state = state.copyWith(isShowingRecentAccess: showing);
}
/// Set tracks from a collection (album/playlist) opened from search results
void setTracksFromCollection({
required List<Track> tracks,
String? albumName,
String? playlistName,
String? coverUrl,
}) {
state = TrackState(
tracks: tracks,
isLoading: false,
albumName: albumName,
playlistName: playlistName,
coverUrl: coverUrl,
hasSearchText: state.hasSearchText,
);
}
Track _parseTrack(Map<String, dynamic> data) {
return Track(
@@ -497,7 +528,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
// Handle duration_ms which might be int or double
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
@@ -506,13 +536,15 @@ class TrackNotifier extends Notifier<TrackState> {
durationMs = durationValue.toInt();
}
final itemType = data['item_type']?.toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
coverUrl: data['images']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -520,6 +552,7 @@ class TrackNotifier extends Notifier<TrackState> {
releaseDate: data['release_date']?.toString(),
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
);
}
@@ -529,9 +562,10 @@ class TrackNotifier extends Notifier<TrackState> {
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['images'] as String?,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
);
}
@@ -548,23 +582,18 @@ class TrackNotifier extends Notifier<TrackState> {
/// Pre-warm track ID cache for faster downloads
/// Runs in background, doesn't block UI
void _preWarmCacheForTracks(List<Track> tracks) {
// Only pre-warm if we have tracks with ISRC
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return;
// Build request list for Go backend
final cacheRequests = tracksWithIsrc.map((t) => {
'isrc': t.isrc!,
'track_name': t.name,
'artist_name': t.artistName,
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
'service': 'tidal', // Default to tidal for pre-warming
'service': 'tidal',
}).toList();
// Fire and forget - runs in background
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
// Silently ignore errors - this is just an optimization
});
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
}
}
+24 -18
View File
@@ -2,10 +2,12 @@ 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:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/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';
@@ -62,7 +64,18 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
@override
void initState() {
super.initState();
// Priority: widget.tracks > cache > fetch
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
if (_tracks == null) {
_fetchTracks();
@@ -74,14 +87,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
try {
Map<String, dynamic> metadata;
// Check if this is a Deezer album ID (format: "deezer:123456")
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
} else {
// Spotify album - use fallback method
// ignore: avoid_print
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
final url = 'https://open.spotify.com/album/${widget.albumId}';
@@ -91,7 +102,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Store in cache
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
@@ -260,7 +270,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -269,7 +279,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -289,7 +299,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
@@ -324,12 +334,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
}
@@ -344,12 +354,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistName: widget.albumName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
@@ -375,7 +385,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
context.l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -383,7 +393,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
context.l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -398,7 +408,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -428,12 +437,10 @@ class _AlbumTrackItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
@@ -444,7 +451,6 @@ class _AlbumTrackItem extends ConsumerWidget {
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
@@ -476,7 +482,7 @@ class _AlbumTrackItem extends ConsumerWidget {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
return;
} else {
+602 -173
View File
@@ -1,50 +1,87 @@
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:intl/intl.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
/// Simple in-memory cache for artist discography
/// Simple in-memory cache for artist data
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
static List<ArtistAlbum>? get(String artistId) {
static _CacheEntry? get(String artistId) {
final entry = _cache[artistId];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiresAt)) {
_cache.remove(artistId);
return null;
}
return entry.albums;
return entry;
}
static void set(String artistId, List<ArtistAlbum> albums) {
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
static void set(String artistId, {
required List<ArtistAlbum> albums,
List<Track>? topTracks,
String? headerImageUrl,
int? monthlyListeners,
}) {
_cache[artistId] = _CacheEntry(
albums: albums,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
monthlyListeners: monthlyListeners,
expiresAt: DateTime.now().add(_ttl),
);
}
}
class _CacheEntry {
final List<ArtistAlbum> albums;
final List<Track>? topTracks;
final String? headerImageUrl;
final int? monthlyListeners;
final DateTime expiresAt;
_CacheEntry(this.albums, this.expiresAt);
_CacheEntry({
required this.albums,
this.topTracks,
this.headerImageUrl,
this.monthlyListeners,
required this.expiresAt,
});
}
/// Artist screen with Material Expressive 3 design - shows discography
/// Artist screen with Spotify-like design
class ArtistScreen extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
final String? coverUrl;
final List<ArtistAlbum>? albums; // Optional - will fetch if null
final String? headerImageUrl;
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
final String? extensionId; // If set, skip fetching from Spotify/Deezer
const ArtistScreen({
super.key,
required this.artistId,
required this.artistName,
this.coverUrl,
this.headerImageUrl,
this.monthlyListeners,
this.albums,
this.topTracks,
this.extensionId,
});
@override
@@ -54,14 +91,55 @@ class ArtistScreen extends ConsumerStatefulWidget {
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _isLoadingDiscography = false;
List<ArtistAlbum>? _albums;
List<Track>? _topTracks;
String? _headerImageUrl;
int? _monthlyListeners;
String? _error;
@override
void initState() {
super.initState();
// Priority: widget.albums > cache > fetch
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
if (_albums == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.extensionId ??
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordArtistAccess(
id: widget.artistId,
name: widget.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
if (widget.extensionId != null) {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
return;
}
final cached = _ArtistCache.get(widget.artistId);
if (widget.albums != null) {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
} else if (cached != null) {
_albums = cached.albums;
_topTracks = cached.topTracks;
_headerImageUrl = cached.headerImageUrl;
_monthlyListeners = cached.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
} else {
_fetchDiscography();
}
}
@@ -70,31 +148,55 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
setState(() => _isLoadingDiscography = true);
try {
List<ArtistAlbum> albums;
List<Track>? topTracks;
String? headerImage;
int? listeners;
// Check if this is a Deezer artist ID (format: "deezer:123456")
if (widget.artistId.startsWith('deezer:')) {
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
} else {
// Spotify artist - use fallback method
// ignore: avoid_print
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
final result = await PlatformBridge.handleURLWithExtension(url);
if (result != null && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
}
headerImage = artistData['header_image'] as String?;
listeners = artistData['listeners'] as int?;
} else {
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
}
}
// Store in cache
_ArtistCache.set(widget.artistId, albums);
final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners;
_ArtistCache.set(
widget.artistId,
albums: albums,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
monthlyListeners: finalListeners,
);
if (mounted) {
setState(() {
_albums = albums;
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_monthlyListeners = finalListeners;
_isLoadingDiscography = false;
});
}
@@ -108,15 +210,41 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
}
Track _parseTrack(Map<String, dynamic> data) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
source: data['provider_id']?.toString(),
);
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['images'] as String?,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
);
}
@@ -129,43 +257,58 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold(
body: Stack(
children: [
CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoadingDiscography && _error == null) ...[
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
body: CustomScrollView(
slivers: [
_buildHeader(context, colorScheme),
if (_isLoadingDiscography)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
)),
if (!_isLoadingDiscography && _error == null) ...[
if (_topTracks != null && _topTracks!.isNotEmpty)
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
if (singles.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)),
if (compilations.isNotEmpty)
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
// Validate image URL - must be non-null, non-empty, and have a valid host
final hasValidImage = widget.coverUrl != null &&
widget.coverUrl!.isNotEmpty &&
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
/// Build Spotify-style header with full-width image and artist name overlay
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
String? imageUrl = _headerImageUrl;
if (imageUrl == null || imageUrl.isEmpty) {
imageUrl = widget.headerImageUrl;
}
if (imageUrl == null || imageUrl.isEmpty) {
imageUrl = widget.coverUrl;
}
final hasValidImage = imageUrl != null &&
imageUrl.isNotEmpty &&
Uri.tryParse(imageUrl)?.hasAuthority == true;
String? listenersText;
final listeners = _monthlyListeners ?? widget.monthlyListeners;
if (listeners != null && listeners > 0) {
final formatter = NumberFormat.compact();
listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners));
}
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 380,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@@ -176,47 +319,79 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
children: [
if (hasValidImage)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
imageUrl: imageUrl,
fit: BoxFit.cover,
alignment: Alignment.topCenter, // Show top of image (faces)
memCacheWidth: 800,
placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
),
)
else
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
stops: const [0.0, 0.7, 1.0],
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.7),
colorScheme.surface,
],
stops: const [0.0, 0.5, 0.75, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.artistName,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 4,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
child: ClipOval(
child: hasValidImage
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: 280,
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
),
)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
if (listenersText != null) ...[
const SizedBox(height: 4),
Text(
listenersText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.8),
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 2,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
),
],
],
),
),
],
@@ -224,44 +399,272 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
leading: IconButton(
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
if (_albums != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
],
/// Build Popular tracks section like Spotify
Widget _buildPopularSection(ColorScheme colorScheme) {
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
final tracks = _topTracks!.take(5).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
context.l10n.artistPopular,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return _buildPopularTrackItem(index + 1, track, colorScheme);
}),
],
);
}
/// Build a single popular track item with dynamic download status
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return InkWell(
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
SizedBox(
width: 24,
child: Text(
'$rank',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: track.coverUrl != null
? CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
placeholder: (context, url) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
),
)
: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (track.albumName.isNotEmpty)
Text(
track.albumName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
_buildPopularDownloadButton(
track: track,
colorScheme: colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
progress: progress,
),
],
),
),
);
}
/// Handle tap on popular track item
void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory}) async {
if (isQueued) return;
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
);
}
return;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
}
}
}
_downloadTrack(track);
}
/// Build download button with status indicator for popular tracks
Widget _buildPopularDownloadButton({
required Track track,
required ColorScheme colorScheme,
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required double progress,
}) {
const double size = 40.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize),
),
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14),
],
),
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 2.5,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: colorScheme.primary),
),
],
),
);
} else if (isQueued) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize),
);
} else {
return GestureDetector(
onTap: () => _downloadTrack(track),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize),
),
);
}
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
duration: const Duration(seconds: 2),
),
);
}
@@ -271,24 +674,26 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Icon(Icons.album, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
],
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
'$title (${albums.length})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
height: 210,
height: 220,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
return KeyedSubtree(
key: ValueKey(album.id),
child: _buildAlbumCard(album, colorScheme),
);
},
),
),
@@ -301,62 +706,87 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
onTap: () => _navigateToAlbum(album),
child: Container(
width: 140,
margin: const EdgeInsets.symmetric(horizontal: 6),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverUrl != null
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
),
const SizedBox(height: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks'
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
width: 140,
height: 140,
fit: BoxFit.cover,
memCacheWidth: 280,
placeholder: (context, url) => Container(
width: 140,
height: 140,
color: colorScheme.surfaceContainerHighest,
),
],
),
),
],
errorWidget: (context, url, error) => Container(
width: 140,
height: 140,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
),
)
: Container(
width: 140,
height: 140,
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
),
),
),
const SizedBox(height: 8),
Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
void _navigateToAlbum(ArtistAlbum album) {
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
ref.read(settingsProvider.notifier).setHasSearchedBefore();
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
// tracks: null - will be fetched in AlbumScreen
),
));
if (album.providerId != null && album.providerId!.isNotEmpty) {
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: album.providerId!,
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
),
));
} else {
Navigator.push(context, MaterialPageRoute(
builder: (context) => AlbumScreen(
albumId: album.id,
albumName: album.name,
coverUrl: album.coverUrl,
),
));
}
}
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
@@ -366,7 +796,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return Card(
elevation: 0,
color: colorScheme.errorContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
@@ -378,7 +808,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
context.l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -386,7 +816,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment and try again.',
context.l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -401,11 +831,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
// Default error display
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
+18 -20
View File
@@ -4,6 +4,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -25,7 +27,6 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
@@ -83,19 +84,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
title: Text(context.l10n.downloadedAlbumDeleteSelected),
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
child: Text(context.l10n.dialogDelete),
),
],
),
@@ -124,7 +125,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
);
}
}
@@ -132,11 +133,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Future<void> _openFile(String filePath) async {
try {
await OpenFilex.open(filePath);
final mimeType = audioMimeTypeForPath(filePath);
await OpenFilex.open(filePath, type: mimeType);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
);
}
}
@@ -156,11 +158,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
// Watch history and get tracks for this album (reactive!)
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final tracks = _getAlbumTracks(allHistoryItems);
// Auto-pop if album has less than 2 tracks (no longer an "album")
if (tracks.length < 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.pop(context);
@@ -168,7 +168,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return const SizedBox.shrink();
}
// Clean up selected IDs that no longer exist
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
@@ -197,7 +196,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
],
),
// Bottom Selection Action Bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
@@ -321,7 +319,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -374,13 +372,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
icon: const Icon(Icons.checklist, size: 18),
label: const Text('Select'),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
),
],
@@ -521,11 +519,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$selectedCount selected',
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? 'All tracks selected' : 'Tap tracks to select',
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
@@ -540,7 +538,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? 'Deselect' : 'Select All'),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
),
],
@@ -553,8 +551,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
: 'Select tracks to delete',
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
+90 -17
View File
@@ -6,6 +6,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@@ -73,7 +75,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
setState(() => _currentIndex = index);
switch (index) {
case 0:
// Already on home
break;
case 1:
context.push('/queue');
@@ -110,7 +111,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// URL Input
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
@@ -130,7 +130,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
),
// Error message
if (trackState.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
@@ -140,15 +139,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
),
// Loading indicator
if (trackState.isLoading)
LinearProgressIndicator(color: colorScheme.primary),
// Album/Playlist header
if (trackState.albumName != null || trackState.playlistName != null)
_buildHeader(trackState, colorScheme),
// Download All button
if (trackState.tracks.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
@@ -162,7 +158,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
),
),
// Track list
Expanded(
child: trackState.tracks.isEmpty
? _buildEmptyState(colorScheme)
@@ -250,7 +245,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
],
),
),
// Play all button
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
@@ -267,6 +261,22 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
final track = ref.watch(trackProvider).tracks[index];
final isCollection = track.isCollection;
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
final capitalizedType = typeLabel.isNotEmpty
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
: 'Album';
final year = track.releaseDate != null && track.releaseDate!.length >= 4
? track.releaseDate!.substring(0, 4)
: '';
subtitleText = '$capitalizedType${track.artistName}${year.isNotEmpty ? '$year' : ''}';
} else {
subtitleText = track.artistName;
}
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
@@ -285,22 +295,85 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
child: Icon(
isCollection ? Icons.album : Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
track.artistName,
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => _downloadTrack(index),
trailing: isCollection
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index),
);
}
Future<void> _openCollection(Track track) async {
final extensionId = track.source;
if (extensionId == null) return;
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
if (albumData != null && mounted) {
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
albumName: albumData['name'] as String? ?? track.name,
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
);
}
} else if (track.isPlaylistItem) {
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
if (playlistData != null && mounted) {
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
playlistName: playlistData['name'] as String? ?? track.name,
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load: $e')),
);
}
}
}
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? '').toString(),
albumName: (data['album_name'] ?? '').toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
duration: (durationMs / 1000).round(),
releaseDate: data['release_date']?.toString(),
source: source,
);
}
+1125 -124
View File
File diff suppressed because it is too large Load Diff
+45 -38
View File
@@ -2,8 +2,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/store_tab.dart';
@@ -28,13 +30,12 @@ class _MainShellState extends ConsumerState<MainShell> {
late PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress; // For double-tap to exit
DateTime? _lastBackPress;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
// Check for updates after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
_setupShareListener();
@@ -42,14 +43,12 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _setupShareListener() {
// Check for pending URL that was received before listener was ready
final pendingUrl = ShareIntentService().consumePendingUrl();
if (pendingUrl != null) {
_log.d('Processing pending shared URL: $pendingUrl');
_handleSharedUrl(pendingUrl);
}
// Listen for future shared URLs with error handling
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
(url) {
_log.d('Received shared URL from stream: $url');
@@ -63,21 +62,16 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _handleSharedUrl(String url) {
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
Navigator.of(context).popUntil((route) => route.isFirst);
// Navigate to Home tab
if (_currentIndex != 0) {
_onNavTap(0);
}
// Fetch metadata for shared URL
ref.read(trackProvider.notifier).fetchFromUrl(url);
// Mark that user has searched (hide helper text)
ref.read(settingsProvider.notifier).setHasSearchedBefore();
// Show snackbar
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Loading shared link...')),
SnackBar(content: Text(context.l10n.loadingSharedLink)),
);
}
}
@@ -122,6 +116,7 @@ class _MainShellState extends ConsumerState<MainShell> {
void _onPageChanged(int index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
FocusManager.instance.primaryFocus?.unfocus();
}
}
@@ -129,40 +124,41 @@ class _MainShellState extends ConsumerState<MainShell> {
void _handleBackPress() {
final trackState = ref.read(trackProvider);
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardVisible) {
FocusScope.of(context).unfocus();
FocusManager.instance.primaryFocus?.unfocus();
return;
}
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
FocusManager.instance.primaryFocus?.unfocus();
return;
}
// If on Home tab and has text in search bar or has content (but not loading), clear it
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
ref.read(trackProvider.notifier).clear();
return;
}
// If not on Home tab, go to Home tab first
if (_currentIndex != 0) {
_onNavTap(0);
return;
}
// If loading, ignore back press
if (trackState.isLoading) {
return;
}
// Double-tap to exit
final now = DateTime.now();
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
SystemNavigator.pop();
} else {
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Press back again to exit'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.pressBackAgainToExit),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
@@ -174,8 +170,8 @@ class _MainShellState extends ConsumerState<MainShell> {
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final trackState = ref.watch(trackProvider);
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Determine if we can pop (for predictive back animation)
@@ -185,21 +181,26 @@ class _MainShellState extends ConsumerState<MainShell> {
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading &&
!trackState.isShowingRecentAccess &&
!isKeyboardVisible;
// Build tabs and destinations based on settings
final tabs = <Widget>[
const HomeTab(),
const QueueTab(),
QueueTab(
parentPageController: _pageController,
parentPageIndex: 1,
nextPageIndex: showStore ? 2 : 3,
),
if (showStore) const StoreTab(),
const SettingsTab(),
];
final l10n = context.l10n;
final destinations = <NavigationDestination>[
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: l10n.navHome,
),
NavigationDestination(
icon: Badge(
@@ -212,22 +213,29 @@ class _MainShellState extends ConsumerState<MainShell> {
label: Text('$queueState'),
child: const Icon(Icons.history),
),
label: 'History',
label: l10n.navHistory,
),
if (showStore)
const NavigationDestination(
icon: Icon(Icons.store_outlined),
selectedIcon: Icon(Icons.store),
label: 'Store',
NavigationDestination(
icon: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
),
selectedIcon: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
),
label: l10n.navStore,
),
const NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: l10n.navSettings,
),
];
// Clamp current index if tabs changed
final maxIndex = tabs.length - 1;
if (_currentIndex > maxIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -247,14 +255,13 @@ class _MainShellState extends ConsumerState<MainShell> {
return;
}
// Handle back press manually when canPop is false
_handleBackPress();
},
child: Scaffold(
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const BouncingScrollPhysics(),
physics: const ClampingScrollPhysics(),
children: tabs,
),
bottomNavigationBar: NavigationBar(
+9 -11
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -114,7 +115,7 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
@@ -122,7 +123,7 @@ class PlaylistScreen extends ConsumerWidget {
FilledButton.icon(
onPressed: () => _downloadAll(context, ref),
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -141,7 +142,7 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
],
),
),
@@ -176,12 +177,12 @@ class PlaylistScreen extends ConsumerWidget {
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
}
}
@@ -195,12 +196,12 @@ class PlaylistScreen extends ConsumerWidget {
artistName: playlistName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
}
}
}
@@ -216,12 +217,10 @@ class _PlaylistTrackItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Only watch the specific item for this track
final queueItem = ref.watch(downloadQueueProvider.select((state) {
return state.items.where((item) => item.track.id == track.id).firstOrNull;
}));
// Check if track is in history (already downloaded before)
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
@@ -232,7 +231,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
// Show as downloaded if in queue completed OR in history
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
return Padding(
@@ -264,7 +262,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
return;
} else {
+16 -15
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -14,19 +15,19 @@ class QueueScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Download Queue'),
title: Text(context.l10n.queueTitle),
actions: [
if (queueState.items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: 'Clear completed',
tooltip: context.l10n.queueClearCompleted,
),
if (queueState.items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
tooltip: 'Clear all',
tooltip: context.l10n.queueClearAll,
),
],
),
@@ -51,14 +52,14 @@ class QueueScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
Text(
'No downloads in queue',
context.l10n.queueEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Add tracks from the home screen',
context.l10n.queueEmptySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
@@ -177,7 +178,7 @@ class QueueScreen extends ConsumerWidget {
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
const Text('Download Failed'),
Text(context.l10n.queueDownloadFailed),
],
),
content: SingleChildScrollView(
@@ -185,10 +186,10 @@ class QueueScreen extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('Artist: ${item.track.artistName}'),
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
const SizedBox(height: 16),
const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
@@ -197,7 +198,7 @@ class QueueScreen extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.error ?? 'Unknown error',
item.error ?? context.l10n.queueUnknownError,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
@@ -211,7 +212,7 @@ class QueueScreen extends ConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.dialogClose),
),
],
),
@@ -223,19 +224,19 @@ class QueueScreen extends ConsumerWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear All'),
content: const Text('Are you sure you want to clear all downloads?'),
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
+903 -456
View File
File diff suppressed because it is too large Load Diff
+70 -90
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class AboutPage extends StatelessWidget {
@@ -13,47 +14,43 @@ class AboutPage extends StatelessWidget {
final topPadding = MediaQuery.of(context).padding.top;
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
// When collapsed (expandRatio=0): left=56 to avoid back button
// When expanded (expandRatio=1): left=24 for normal padding
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'About',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// App header card with logo and description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
@@ -61,28 +58,27 @@ class AboutPage extends StatelessWidget {
),
),
// Contributors section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Contributors'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: 'Mobile version developer',
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
_ContributorItem(
name: AppInfo.originalAuthor,
description: 'Creator of the original SpotiFLAC',
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: 'The talented artist who created our beautiful app logo!',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
@@ -90,36 +86,35 @@ class AboutPage extends StatelessWidget {
),
),
// Special Thanks section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Special Thanks'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: 'uimaxbai',
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
githubUsername: 'uimaxbai',
name: 'binimum',
description: context.l10n.aboutBinimumDesc,
githubUsername: 'binimum',
showDivider: true,
),
_ContributorItem(
name: 'sachinsenal0x64',
description: 'The original HiFi project creator. The foundation of Tidal integration!',
description: context.l10n.aboutSachinsenalDesc,
githubUsername: 'sachinsenal0x64',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.cloud_outlined,
title: 'DoubleDouble',
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
title: context.l10n.aboutDoubleDouble,
subtitle: context.l10n.aboutDoubleDoubleDesc,
onTap: () => _launchUrl('https://doubledouble.top'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: 'DAB Music',
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: false,
),
@@ -127,38 +122,37 @@ class AboutPage extends StatelessWidget {
),
),
// Links section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Links'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
_AboutSettingsItem(
icon: Icons.phone_android,
title: 'Mobile source code',
title: context.l10n.aboutMobileSource,
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
SettingsItem(
_AboutSettingsItem(
icon: Icons.computer,
title: 'PC source code',
title: context.l10n.aboutPCSource,
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
SettingsItem(
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: 'Report an issue',
subtitle: 'Report any problems you encounter',
title: context.l10n.aboutReportIssue,
subtitle: context.l10n.aboutReportIssueSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
SettingsItem(
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: 'Feature request',
subtitle: 'Suggest new features for the app',
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
@@ -166,17 +160,16 @@ class AboutPage extends StatelessWidget {
),
),
// Support section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Support'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
_AboutSettingsItem(
icon: Icons.coffee_outlined,
title: 'Buy me a coffee',
subtitle: 'Support development on Ko-fi',
title: context.l10n.aboutBuyMeCoffee,
subtitle: context.l10n.aboutBuyMeCoffeeSubtitle,
onTap: () => _launchUrl(AppInfo.kofiUrl),
showDivider: false,
),
@@ -184,16 +177,15 @@ class AboutPage extends StatelessWidget {
),
),
// App info section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
_AboutSettingsItem(
icon: Icons.info_outline,
title: 'Version',
title: context.l10n.aboutVersion,
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
@@ -201,7 +193,6 @@ class AboutPage extends StatelessWidget {
),
),
// Copyright
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
@@ -216,17 +207,15 @@ class AboutPage extends StatelessWidget {
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
),
),
);
}
static Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
// Use inAppBrowserView for reliable URL opening with app chooser
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
}
}
@@ -249,8 +238,6 @@ class _AppHeaderCard extends StatelessWidget {
padding: const EdgeInsets.all(24),
child: Column(
children: [
// App logo
// App logo
Container(
width: 88,
height: 88,
@@ -274,7 +261,6 @@ class _AppHeaderCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
// App name
Text(
AppInfo.appName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -282,7 +268,6 @@ class _AppHeaderCard extends StatelessWidget {
),
),
const SizedBox(height: 4),
// Version badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
@@ -298,9 +283,8 @@ class _AppHeaderCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
// Description
Text(
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -340,7 +324,6 @@ class _ContributorItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
// GitHub Avatar
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
@@ -371,7 +354,6 @@ class _ContributorItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Name and description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -390,7 +372,6 @@ class _ContributorItem extends StatelessWidget {
],
),
),
// GitHub icon
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
],
),
@@ -445,7 +426,6 @@ class _AboutSettingsItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
// Icon with 40x40 size to match avatar
SizedBox(
width: 40,
height: 40,
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/l10n/supported_locales.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -15,29 +17,27 @@ class AppearanceSettingsPage extends ConsumerWidget {
final topPadding = MediaQuery.of(context).padding.top;
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: 'Appearance',
topPadding: topPadding,
),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
// Preview Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -48,9 +48,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Color section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Color'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionColor),
),
SliverToBoxAdapter(
@@ -58,8 +57,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
children: [
SettingsSwitchItem(
icon: Icons.wallpaper,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
title: context.l10n.appearanceDynamicColor,
subtitle: context.l10n.appearanceDynamicColorSubtitle,
value: themeSettings.useDynamicColor,
onChanged: (value) => ref
.read(themeProvider.notifier)
@@ -78,12 +77,11 @@ class AppearanceSettingsPage extends ConsumerWidget {
onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color),
),
),
),
),
// Theme section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Theme'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -96,8 +94,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
if (Theme.of(context).brightness == Brightness.dark)
SettingsSwitchItem(
icon: Icons.brightness_2,
title: 'AMOLED Dark',
subtitle: 'Pure black background',
title: context.l10n.appearanceAmoledDark,
subtitle: context.l10n.appearanceAmoledDarkSubtitle,
value: themeSettings.useAmoled,
onChanged: (value) =>
ref.read(themeProvider.notifier).setUseAmoled(value),
@@ -107,9 +105,24 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Layout section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Layout'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLanguage),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_LanguageSelector(
currentLocale: settings.locale,
onChanged: (locale) => ref
.read(settingsProvider.notifier)
.setLocale(locale),
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -124,7 +137,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
),
// Fill remaining for scroll
const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(height: 32),
@@ -155,7 +167,6 @@ class _ThemePreviewCard extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Decorative background blobs
Positioned(
top: -50,
right: -50,
@@ -181,7 +192,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
),
// Foreground "fake UI"
Center(
child: Container(
width: 260,
@@ -200,7 +210,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
child: Row(
children: [
// Fake Album Art
Container(
width: 108,
height: 108,
@@ -216,7 +225,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
const SizedBox(width: 16),
// Fake Text Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -269,7 +277,6 @@ class _ThemePreviewCard extends StatelessWidget {
),
),
// Label badge
Positioned(
bottom: 12,
right: 12,
@@ -283,7 +290,7 @@ class _ThemePreviewCard extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? 'Dark Mode' : 'Light Mode',
isDark ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
@@ -451,21 +458,21 @@ class _ThemeModeSelector extends StatelessWidget {
children: [
_ThemeModeChip(
icon: Icons.brightness_auto,
label: 'System',
label: context.l10n.appearanceThemeSystem,
isSelected: currentMode == ThemeMode.system,
onTap: () => onChanged(ThemeMode.system),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.light_mode,
label: 'Light',
label: context.l10n.appearanceThemeLight,
isSelected: currentMode == ThemeMode.light,
onTap: () => onChanged(ThemeMode.light),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.dark_mode,
label: 'Dark',
label: context.l10n.appearanceThemeDark,
isSelected: currentMode == ThemeMode.dark,
onTap: () => onChanged(ThemeMode.dark),
),
@@ -491,10 +498,7 @@ class _ThemeModeChip extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
@@ -575,7 +579,7 @@ class _HistoryViewSelector extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text(
'History View',
context.l10n.appearanceHistoryView,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -585,14 +589,14 @@ class _HistoryViewSelector extends StatelessWidget {
children: [
_ViewModeChip(
icon: Icons.view_list,
label: 'List',
label: context.l10n.appearanceHistoryViewList,
isSelected: currentMode == 'list',
onTap: () => onChanged('list'),
),
const SizedBox(width: 8),
_ViewModeChip(
icon: Icons.grid_view,
label: 'Grid',
label: context.l10n.appearanceHistoryViewGrid,
isSelected: currentMode == 'grid',
onTap: () => onChanged('grid'),
),
@@ -621,7 +625,6 @@ class _ViewModeChip extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Unselected chips need contrast with card background
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
@@ -682,3 +685,128 @@ class _ViewModeChip extends StatelessWidget {
);
}
}
class _LanguageSelector extends StatelessWidget {
final String currentLocale;
final ValueChanged<String> onChanged;
const _LanguageSelector({
required this.currentLocale,
required this.onChanged,
});
static const _allLanguages = [
('system', 'System Default', Icons.phone_android),
('en', 'English', Icons.language),
('id', 'Bahasa Indonesia', Icons.language),
('de', 'Deutsch', Icons.language),
('es', 'Español', Icons.language),
('fr', 'Français', Icons.language),
('hi', 'हिन्दी', Icons.language),
('ja', '日本語', Icons.language),
('ko', '한국어', Icons.language),
('nl', 'Nederlands', Icons.language),
('pt', 'Português', Icons.language),
('ru', 'Русский', Icons.language),
('zh', '简体中文', Icons.language),
('zh_TW', '繁體中文', Icons.language),
];
/// Get only languages that meet the translation threshold.
/// Uses filteredLocaleCodes from supported_locales.dart (generated file).
List<(String, String, IconData)> get _languages {
return _allLanguages.where((lang) {
if (lang.$1 == 'system') return true;
return filteredLocaleCodes.contains(lang.$1);
}).toList();
}
String _getLanguageName(String code) {
for (final lang in _allLanguages) {
if (lang.$1 == code) return lang.$2;
}
return code;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(
Icons.language,
color: colorScheme.onSurfaceVariant,
),
title: Text(context.l10n.appearanceLanguage),
subtitle: Text(_getLanguageName(currentLocale)),
trailing: Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
onTap: () => _showLanguagePicker(context),
);
}
void _showLanguagePicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.appearanceLanguage,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const Divider(height: 1),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: _languages.length,
itemBuilder: (context, index) {
final lang = _languages[index];
final isSelected = currentLocale == lang.$1;
return ListTile(
leading: Icon(
lang.$3,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
lang.$2,
style: TextStyle(
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
onChanged(lang.$1);
Navigator.pop(context);
},
);
},
),
),
const SizedBox(height: 8),
],
),
),
);
}
}
+214 -146
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -10,7 +11,6 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerWidget {
const DownloadSettingsPage({super.key});
// Built-in services that support quality options
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
@override
@@ -19,57 +19,54 @@ class DownloadSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
// Check if current service is built-in (supports quality options)
final isBuiltInService = _builtInServices.contains(settings.defaultService);
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
'Download',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.downloadTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Service section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Service'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionService),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -84,21 +81,19 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
// Quality section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Audio Quality'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.tune,
title: 'Ask Before Download',
title: context.l10n.downloadAskBeforeDownload,
subtitle: isBuiltInService
? 'Choose quality for each download'
? context.l10n.downloadAskQualitySubtitle
: 'Select a built-in service to enable',
value: settings.askQualityBeforeDownload,
// Not selected visually if extension is active
enabled: isBuiltInService,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -106,24 +101,24 @@ class DownloadSettingsPage extends ConsumerWidget {
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
title: context.l10n.qualityFlacLossless,
subtitle: context.l10n.qualityFlacLosslessSubtitle,
isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('LOSSLESS'),
),
_QualityOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
title: context.l10n.qualityHiResFlac,
subtitle: context.l10n.qualityHiResFlacSubtitle,
isSelected: settings.audioQuality == 'HI_RES',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES'),
),
_QualityOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
title: context.l10n.qualityHiResFlacMax,
subtitle: context.l10n.qualityHiResFlacMaxSubtitle,
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
@@ -158,16 +153,15 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
// File settings section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'File Settings'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.text_fields,
title: 'Filename Format',
title: context.l10n.downloadFilenameFormat,
subtitle: settings.filenameFormat,
onTap: () => _showFormatEditor(
context,
@@ -177,17 +171,17 @@ class DownloadSettingsPage extends ConsumerWidget {
),
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
title: context.l10n.downloadDirectory,
subtitle: settings.downloadDirectory.isEmpty
? (Platform.isIOS
? 'App Documents Folder'
? context.l10n.setupAppDocumentsFolder
: 'Music/SpotiFLAC')
: settings.downloadDirectory,
onTap: () => _pickDirectory(context, ref),
),
SettingsSwitchItem(
icon: Icons.library_music_outlined,
title: 'Separate Singles Folder',
title: context.l10n.downloadSeparateSinglesFolder,
subtitle: settings.separateSingles
? 'Albums/ and Singles/ folders'
: 'All files in same structure',
@@ -196,10 +190,21 @@ class DownloadSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier)
.setSeparateSingles(value),
),
if (settings.separateSingles)
SettingsItem(
icon: Icons.folder_outlined,
title: context.l10n.downloadAlbumFolderStructure,
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
onTap: () => _showAlbumFolderStructurePicker(
context,
ref,
settings.albumFolderStructure,
),
),
if (!settings.separateSingles)
SettingsItem(
icon: Icons.create_new_folder_outlined,
title: 'Folder Organization',
title: context.l10n.downloadFolderOrganization,
subtitle: _getFolderOrganizationLabel(
settings.folderOrganization,
),
@@ -221,6 +226,72 @@ class DownloadSettingsPage extends ConsumerWidget {
);
}
String _getAlbumFolderStructureLabel(String structure) {
switch (structure) {
case 'album_only':
return 'Albums/Album Name/';
case 'artist_year_album':
return 'Albums/Artist/[Year] Album/';
case 'year_album':
return 'Albums/[Year] Album/';
default:
return 'Albums/Artist/Album Name/';
}
}
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(context.l10n.albumFolderArtistAlbum),
subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle),
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.calendar_today_outlined),
title: Text(context.l10n.albumFolderArtistYearAlbum),
subtitle: Text(context.l10n.albumFolderArtistYearAlbumSubtitle),
trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.album_outlined),
title: Text(context.l10n.albumFolderAlbumOnly),
subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle),
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.event_outlined),
title: Text(context.l10n.albumFolderYearAlbum),
subtitle: Text(context.l10n.albumFolderYearAlbumSubtitle),
trailing: current == 'year_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album');
Navigator.pop(context);
},
),
],
),
),
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
@@ -243,11 +314,9 @@ class DownloadSettingsPage extends ConsumerWidget {
String insertion = tag;
if (start > 0) {
final before = text.substring(0, start);
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
if (!before.trim().endsWith('-')) {
insertion = ' - $tag';
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
// If ends with '-' but no space, add space
insertion = ' $tag';
}
}
@@ -290,7 +359,7 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
Text(
'Filename Format',
context.l10n.filenameFormat,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -356,7 +425,7 @@ class DownloadSettingsPage extends ConsumerWidget {
Row(
children: [
Expanded(
child: TextButton(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
@@ -364,7 +433,7 @@ class DownloadSettingsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
),
const SizedBox(width: 12),
@@ -383,7 +452,7 @@ class DownloadSettingsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Save Format'),
child: Text(context.l10n.dialogSave),
),
),
],
@@ -400,10 +469,8 @@ class DownloadSettingsPage extends ConsumerWidget {
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
if (Platform.isIOS) {
// iOS: Show options dialog
_showIOSDirectoryOptions(context, ref);
} else {
// Android: Use file picker
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
@@ -427,7 +494,7 @@ class DownloadSettingsPage extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Download Location',
context.l10n.setupDownloadLocationTitle,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -436,7 +503,7 @@ class DownloadSettingsPage extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
context.l10n.setupDownloadLocationIosMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -444,8 +511,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
title: Text(context.l10n.setupAppDocumentsFolder),
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await getApplicationDocumentsDirectory();
@@ -457,8 +524,8 @@ class DownloadSettingsPage extends ConsumerWidget {
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
title: Text(context.l10n.setupChooseFromFiles),
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
@@ -488,7 +555,7 @@ class DownloadSettingsPage extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
context.l10n.setupIosEmptyFolderWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -512,7 +579,7 @@ class DownloadSettingsPage extends ConsumerWidget {
case 'album':
return 'By Album';
case 'artist_album':
return 'By Artist & Album';
return 'Artist/Album';
default:
return 'None';
}
@@ -527,74 +594,80 @@ class DownloadSettingsPage extends ConsumerWidget {
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Folder Organization',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Organize downloaded files into folders',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Folder Organization',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
),
_FolderOption(
title: 'None',
subtitle: 'All files in download folder',
example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('none');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist',
subtitle: 'Separate folder for each artist',
example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Album',
subtitle: 'Separate folder for each album',
example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('album');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist & Album',
subtitle: 'Nested folders for artist and album',
example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.folderOrganizationDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
_FolderOption(
title: context.l10n.folderOrganizationNone,
subtitle: context.l10n.folderOrganizationNoneSubtitle,
example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('none');
Navigator.pop(context);
},
),
_FolderOption(
title: context.l10n.folderOrganizationByArtist,
subtitle: context.l10n.folderOrganizationByArtistSubtitle,
example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
Navigator.pop(context);
},
),
_FolderOption(
title: context.l10n.folderOrganizationByAlbum,
subtitle: context.l10n.folderOrganizationByAlbumSubtitle,
example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('album');
Navigator.pop(context);
},
),
_FolderOption(
title: context.l10n.folderOrganizationByArtistAlbum,
subtitle: context.l10n.folderOrganizationByArtistAlbumSubtitle,
example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
),
);
@@ -613,18 +686,15 @@ class _ServiceSelector extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
// Get enabled extension download providers
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
.toList();
// Check if current service is an extension that's now disabled
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService)
: true;
// If current extension is disabled, show it as not selected
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
return Padding(
@@ -655,7 +725,6 @@ class _ServiceSelector extends ConsumerWidget {
),
],
),
// Show extension download providers if any
if (extensionProviders.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
@@ -671,7 +740,6 @@ class _ServiceSelector extends ConsumerWidget {
),
),
],
// Fill remaining space if less than 3 extensions
for (int i = extensionProviders.length; i < 3; i++) ...[
const SizedBox(width: 8),
const Expanded(child: SizedBox()),
+186 -101
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -56,11 +57,12 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final topPadding = MediaQuery.of(context).padding.top;
final hasError = extension.status == 'error';
return Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
@@ -95,7 +97,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
// Extension Info Card
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -184,11 +185,12 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
const SizedBox(height: 16),
_InfoRow(label: 'Author', value: extension.author),
_InfoRow(label: 'ID', value: extension.id),
_InfoRow(label: context.l10n.extensionAuthor, value: extension.author),
_InfoRow(label: context.l10n.extensionId, value: extension.id),
_InfoRow(label: context.l10n.extensionsVersion(extension.version), value: ''),
if (hasError && extension.errorMessage != null)
_InfoRow(
label: 'Error',
label: context.l10n.extensionError,
value: extension.errorMessage!,
isError: true,
),
@@ -198,43 +200,50 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
// Capabilities
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Capabilities'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_CapabilityItem(
icon: Icons.search,
title: 'Metadata Provider',
title: context.l10n.extensionMetadataProvider,
enabled: extension.hasMetadataProvider,
),
_CapabilityItem(
icon: Icons.download,
title: 'Download Provider',
title: context.l10n.extensionDownloadProvider,
enabled: extension.hasDownloadProvider,
),
_CapabilityItem(
icon: Icons.manage_search,
title: 'Custom Search',
title: context.l10n.extensionsSearchProvider,
enabled: extension.hasCustomSearch,
subtitle: extension.searchBehavior?.placeholder,
),
_CapabilityItem(
icon: Icons.compare_arrows,
title: 'Custom Track Matching',
title: context.l10n.extensionCustomTrackMatching,
enabled: extension.hasCustomMatching,
subtitle: extension.trackMatching?.strategy != null
? 'Strategy: ${extension.trackMatching!.strategy}'
? context.l10n.extensionStrategy(extension.trackMatching!.strategy!)
: null,
),
_CapabilityItem(
icon: Icons.auto_fix_high,
title: 'Post-Processing',
title: context.l10n.extensionPostProcessing,
enabled: extension.hasPostProcessing,
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
? '${extension.postProcessing!.hooks.length} hook(s) available'
? context.l10n.extensionHooksAvailable(extension.postProcessing!.hooks.length)
: null,
),
_CapabilityItem(
icon: Icons.link,
title: context.l10n.extensionUrlHandler,
enabled: extension.hasURLHandler,
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
? context.l10n.extensionPatternsCount(extension.urlHandler!.patterns.length)
: null,
showDivider: false,
),
@@ -242,26 +251,42 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
// Search Provider Section (if extension has custom search)
if (extension.hasCustomSearch) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Search Provider'),
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_SearchProviderInfo(
extension: extension,
_URLHandlerInfo(
patterns: extension.urlHandler!.patterns,
),
],
),
),
],
// Post-Processing Hooks (if available)
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: extension.qualityOptions.asMap().entries.map((entry) {
final index = entry.key;
final quality = entry.value;
return _QualityOptionItem(
quality: quality,
showDivider: index < extension.qualityOptions.length - 1,
);
}).toList(),
),
),
],
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Post-Processing Hooks'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -277,10 +302,9 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Permissions
if (extension.permissions.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Permissions'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -296,10 +320,9 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Settings
if (extension.settings.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Settings'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
),
if (_isLoadingSettings)
const SliverToBoxAdapter(
@@ -325,14 +348,13 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
// Remove button
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: OutlinedButton.icon(
onPressed: () => _confirmRemove(context),
icon: const Icon(Icons.delete_outline),
label: const Text('Remove Extension'),
label: Text(context.l10n.extensionRemoveButton),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
@@ -348,6 +370,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
@@ -365,22 +388,21 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove Extension'),
content: const Text(
'Are you sure you want to remove this extension? '
'This action cannot be undone.',
title: Text(context.l10n.dialogRemoveExtension),
content: Text(
context.l10n.dialogRemoveExtensionMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
),
child: const Text('Remove'),
child: Text(context.l10n.dialogRemove),
),
],
),
@@ -391,7 +413,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
.read(extensionProvider.notifier)
.removeExtension(widget.extensionId);
if (success && mounted) {
// Refresh store to update isInstalled status
ref.read(storeProvider.notifier).refresh();
Navigator.pop(this.context);
}
@@ -524,7 +545,6 @@ class _PermissionItem extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Parse permission to get icon and description
IconData icon = Icons.security;
String description = permission;
@@ -692,7 +712,7 @@ class _SettingItem extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
@@ -702,7 +722,7 @@ class _SettingItem extends StatelessWidget {
onChanged(newValue);
Navigator.pop(context);
},
child: const Text('Save'),
child: Text(context.l10n.dialogSave),
),
],
),
@@ -817,17 +837,18 @@ class _PostProcessingHookItem extends StatelessWidget {
}
}
class _SearchProviderInfo extends StatelessWidget {
final Extension extension;
const _SearchProviderInfo({
required this.extension,
class _URLHandlerInfo extends StatelessWidget {
final List<String> patterns;
const _URLHandlerInfo({
required this.patterns,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final searchBehavior = extension.searchBehavior;
return Padding(
padding: const EdgeInsets.all(16),
@@ -840,12 +861,12 @@ class _SearchProviderInfo extends StatelessWidget {
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.manage_search,
color: colorScheme.onSecondaryContainer,
Icons.link,
color: colorScheme.onTertiaryContainer,
size: 24,
),
),
@@ -855,14 +876,14 @@ class _SearchProviderInfo extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Custom Search Available',
'Custom URL Handling',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'This extension provides its own search functionality',
'This extension can handle links from these sites',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -873,25 +894,38 @@ class _SearchProviderInfo extends StatelessWidget {
],
),
const SizedBox(height: 16),
// Search placeholder info
if (searchBehavior?.placeholder != null) ...[
_InfoTile(
icon: Icons.text_fields,
label: 'Search Hint',
value: searchBehavior!.placeholder!,
),
const SizedBox(height: 8),
],
// Primary search info
_InfoTile(
icon: searchBehavior?.primary == true ? Icons.star : Icons.star_border,
label: 'Priority',
value: searchBehavior?.primary == true
? 'Primary search provider'
: 'Secondary search provider',
Wrap(
spacing: 8,
runSpacing: 8,
children: patterns.map((pattern) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.language,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
pattern,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
);
}).toList(),
),
const SizedBox(height: 16),
// Usage instructions
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -908,7 +942,7 @@ class _SearchProviderInfo extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'To use this search provider, tap the search bar on the Home tab and select "${extension.displayName}" from the provider chips.',
'Share links from these sites to SpotiFLAC and this extension will handle them.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -923,44 +957,95 @@ class _SearchProviderInfo extends StatelessWidget {
}
}
class _InfoTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
class _QualityOptionItem extends StatelessWidget {
final QualityOption quality;
final bool showDivider;
const _InfoTile({
required this.icon,
required this.label,
required this.value,
const _QualityOptionItem({
required this.quality,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'$label: ',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.high_quality,
color: colorScheme.onSecondaryContainer,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
quality.label,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (quality.description != null && quality.description!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
quality.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 4),
Text(
quality.id,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.primary,
fontFamily: 'monospace',
),
),
],
),
),
if (quality.settings.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${quality.settings.length} setting${quality.settings.length > 1 ? 's' : ''}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 72,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
),
],
);
}
+35 -56
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
@@ -31,7 +32,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
// Create directories if they don't exist
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
@@ -45,10 +45,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -72,7 +73,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Extensions',
context.l10n.extensionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -84,7 +85,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Loading indicator
if (extState.isLoading)
const SliverToBoxAdapter(
child: Padding(
@@ -93,7 +93,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Error message
if (extState.error != null)
SliverToBoxAdapter(
child: Padding(
@@ -120,9 +119,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Provider Priority
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Provider Priority'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -134,9 +132,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Installed Extensions
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Installed Extensions'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
),
if (extState.extensions.isEmpty && !extState.isLoading)
@@ -158,14 +155,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
const SizedBox(height: 12),
Text(
'No extensions installed',
context.l10n.extensionsNoExtensions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Install .spotiflac-ext files to add new providers',
context.l10n.extensionsNoExtensionsSubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -200,14 +197,13 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Install button
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _installExtension,
icon: const Icon(Icons.add),
label: const Text('Install Extension'),
label: Text(context.l10n.extensionsInstallButton),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
@@ -218,7 +214,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
// Info section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
@@ -234,8 +229,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
const SizedBox(width: 12),
Expanded(
child: Text(
'Extensions can add new metadata and download providers. '
'Only install extensions from trusted sources.',
context.l10n.extensionsInfoTip,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -248,6 +242,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
],
),
),
);
}
@@ -263,8 +258,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
if (!file.path!.endsWith('.spotiflac-ext')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a .spotiflac-ext file'),
SnackBar(
content: Text(context.l10n.snackbarSelectExtFile),
),
);
}
@@ -279,13 +274,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final extState = ref.read(extensionProvider);
String message;
if (success) {
message = 'Extension installed successfully';
message = context.l10n.extensionsInstalledSuccess;
} else {
// Parse friendly error message
message = _getFriendlyErrorMessage(extState.error);
}
// Clear the error from state to avoid showing it twice (in error container)
ref.read(extensionProvider.notifier).clearError();
ScaffoldMessenger.of(context).showSnackBar(
@@ -302,15 +295,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
String message = error;
// Remove PlatformException wrapper if present
// Format: PlatformException(ERROR, actual message, null, null)
if (message.contains('PlatformException')) {
// Try to extract the actual error message
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
if (match != null) {
message = match.group(1)?.trim() ?? message;
} else {
// Fallback: try simpler extraction
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
if (simpleMatch != null) {
message = simpleMatch.group(1)?.trim() ?? message;
@@ -318,7 +307,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
}
}
// Clean up any remaining artifacts
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
@@ -353,7 +341,6 @@ class _ExtensionItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Extension icon
Container(
width: 44,
height: 44,
@@ -387,7 +374,6 @@ class _ExtensionItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Extension info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -401,8 +387,8 @@ class _ExtensionItem extends StatelessWidget {
const SizedBox(height: 2),
Text(
hasError
? extension.errorMessage ?? 'Error loading extension'
: 'v${extension.version} by ${extension.author}',
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
? colorScheme.error
@@ -412,7 +398,6 @@ class _ExtensionItem extends StatelessWidget {
],
),
),
// Toggle switch
Switch(
value: extension.enabled,
onChanged: hasError ? null : onToggle,
@@ -442,7 +427,6 @@ class _DownloadPriorityItem extends ConsumerWidget {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Check if any extension has download provider
final hasDownloadExtensions = extState.extensions
.any((e) => e.enabled && e.hasDownloadProvider);
@@ -471,7 +455,7 @@ class _DownloadPriorityItem extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Download Priority',
context.l10n.extensionsDownloadPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions
? null
@@ -481,8 +465,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
const SizedBox(height: 2),
Text(
hasDownloadExtensions
? 'Set download service order'
: 'No extensions with download provider',
? context.l10n.extensionsDownloadPrioritySubtitle
: context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -511,7 +495,6 @@ class _MetadataPriorityItem extends ConsumerWidget {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Check if any extension has metadata provider
final hasMetadataExtensions = extState.extensions
.any((e) => e.enabled && e.hasMetadataProvider);
@@ -540,7 +523,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Metadata Priority',
context.l10n.extensionsMetadataPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasMetadataExtensions
? null
@@ -550,8 +533,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
const SizedBox(height: 2),
Text(
hasMetadataExtensions
? 'Set search & metadata source order'
: 'No extensions with metadata provider',
? context.l10n.extensionsMetadataPrioritySubtitle
: context.l10n.extensionsNoMetadataProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -581,13 +564,11 @@ class _SearchProviderSelector extends ConsumerWidget {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
// Get extensions with custom search
final searchProviders = extState.extensions
.where((e) => e.enabled && e.hasCustomSearch)
.toList();
// Get current provider name
String currentProviderName = 'Default (Deezer/Spotify)';
String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
currentProviderName = ext?.displayName ?? settings.searchProvider!;
@@ -616,7 +597,7 @@ class _SearchProviderSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Search Provider',
context.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: searchProviders.isEmpty
? colorScheme.outline
@@ -626,7 +607,7 @@ class _SearchProviderSelector extends ConsumerWidget {
const SizedBox(height: 2),
Text(
searchProviders.isEmpty
? 'No extensions with custom search'
? context.l10n.extensionsNoCustomSearch
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -671,7 +652,7 @@ class _SearchProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Search Provider',
ctx.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -680,17 +661,16 @@ class _SearchProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose which service to use for searching tracks',
ctx.l10n.extensionsSearchProviderDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
// Default option
ListTile(
leading: Icon(Icons.music_note, color: colorScheme.primary),
title: const Text('Default (Deezer/Spotify)'),
subtitle: const Text('Use built-in search'),
title: Text(ctx.l10n.extensionDefaultProvider),
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -699,11 +679,10 @@ class _SearchProviderSelector extends ConsumerWidget {
Navigator.pop(ctx);
},
),
// Extension options
...searchProviders.map((ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'),
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
trailing: settings.searchProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
+63 -86
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -24,14 +25,12 @@ class _LogScreenState extends State<LogScreen> {
void initState() {
super.initState();
LogBuffer().addListener(_onLogUpdate);
// Start polling Go backend logs
LogBuffer().startGoLogPolling();
}
@override
void dispose() {
LogBuffer().removeListener(_onLogUpdate);
// Stop polling when leaving screen
LogBuffer().stopGoLogPolling();
_scrollController.dispose();
_searchController.dispose();
@@ -67,7 +66,7 @@ class _LogScreenState extends State<LogScreen> {
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Logs copied to clipboard'),
content: Text(context.l10n.logCopied),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
@@ -84,19 +83,19 @@ class _LogScreenState extends State<LogScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Logs'),
content: const Text('Are you sure you want to clear all logs?'),
title: Text(context.l10n.logClearLogsTitle),
content: Text(context.l10n.logClearLogsMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
LogBuffer().clear();
Navigator.pop(context);
},
child: const Text('Clear'),
child: Text(context.l10n.dialogClear),
),
],
),
@@ -125,60 +124,59 @@ class _LogScreenState extends State<LogScreen> {
final logs = _filteredLogs;
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
// Collapsing App Bar with back button - same as other settings pages
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
actions: [
IconButton(
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
onPressed: () => setState(() => _autoScroll = !_autoScroll),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
onPressed: _copyLogs,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'share':
_shareLogs();
break;
case 'clear':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'share',
child: ListTile(
leading: Icon(Icons.share),
title: Text('Share logs'),
contentPadding: EdgeInsets.zero,
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy logs',
onPressed: _copyLogs,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'share':
_shareLogs();
break;
case 'clear':
_clearLogs();
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'share',
child: ListTile(
leading: const Icon(Icons.share),
title: Text(context.l10n.logShareLogs),
contentPadding: EdgeInsets.zero,
),
const PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Clear logs'),
),
PopupMenuItem(
value: 'clear',
child: ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.logClearLogs),
contentPadding: EdgeInsets.zero,
),
),
@@ -195,7 +193,7 @@ class _LogScreenState extends State<LogScreen> {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Logs',
context.l10n.logTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -207,14 +205,12 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Filter section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Filter'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
// Level filter
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
@@ -225,10 +221,10 @@ class _LogScreenState extends State<LogScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
Text(context.l10n.logFilterLevel, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(
'Filter logs by severity',
context.l10n.logFilterBySeverity,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -268,7 +264,6 @@ class _LogScreenState extends State<LogScreen> {
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
// Search field
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
@@ -279,7 +274,7 @@ class _LogScreenState extends State<LogScreen> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search logs...',
hintText: context.l10n.logSearchHint,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -313,19 +308,18 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Log entries section
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
? context.l10n.logEntriesFiltered(logs.length)
: context.l10n.logEntries(logs.length),
),
),
// Error summary card - shows detected issues
SliverToBoxAdapter(
child: _LogSummaryCard(logs: LogBuffer().entries),
),
// Log list
logs.isEmpty
? SliverToBoxAdapter(
child: SettingsGroup(
@@ -342,14 +336,14 @@ class _LogScreenState extends State<LogScreen> {
),
const SizedBox(height: 16),
Text(
'No logs yet',
context.l10n.logNoLogsYet,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Logs will appear here as you use the app',
context.l10n.logNoLogsYetSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
@@ -376,7 +370,6 @@ class _LogScreenState extends State<LogScreen> {
),
),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
@@ -415,7 +408,6 @@ class _LogEntryTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: time, level, tag
Row(
children: [
Text(
@@ -475,7 +467,6 @@ class _LogEntryTile extends StatelessWidget {
],
),
const SizedBox(height: 6),
// Message
Text(
entry.message,
style: TextStyle(
@@ -485,7 +476,6 @@ class _LogEntryTile extends StatelessWidget {
height: 1.4,
),
),
// Error if present
if (entry.error != null) ...[
const SizedBox(height: 4),
Text(
@@ -523,10 +513,8 @@ class _LogSummaryCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Analyze logs for issues
final analysis = _analyzeLogs();
// Don't show if no issues detected
if (!analysis.hasIssues) {
return const SizedBox.shrink();
}
@@ -544,7 +532,6 @@ class _LogSummaryCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Icon(
@@ -564,7 +551,6 @@ class _LogSummaryCard extends StatelessWidget {
),
const SizedBox(height: 12),
// ISP Blocking detected
if (analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.block,
@@ -577,7 +563,6 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
],
// Rate limiting
if (analysis.hasRateLimit) ...[
_IssueBadge(
icon: Icons.speed,
@@ -589,7 +574,6 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
],
// Network errors
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
_IssueBadge(
icon: Icons.wifi_off,
@@ -601,7 +585,6 @@ class _LogSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
],
// Track not found
if (analysis.hasNotFound) ...[
_IssueBadge(
icon: Icons.search_off,
@@ -612,7 +595,6 @@ class _LogSummaryCard extends StatelessWidget {
),
],
// Error count
const SizedBox(height: 12),
Text(
'Total errors: ${analysis.errorCount}',
@@ -644,7 +626,6 @@ class _LogSummaryCard extends StatelessWidget {
final errorLower = (log.error ?? '').toLowerCase();
final combined = '$msgLower $errorLower';
// Check for ISP blocking (detected by Go backend)
if (combined.contains('isp blocking') ||
combined.contains('isp may be') ||
combined.contains('blocked by isp') ||
@@ -652,21 +633,18 @@ class _LogSummaryCard extends StatelessWidget {
combined.contains('connection refused')) {
hasISPBlocking = true;
// Try to extract domain
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
if (domainMatch != null) {
blockedDomains.add(domainMatch.group(1)!);
}
}
// Check for rate limiting
if (combined.contains('rate limit') ||
combined.contains('429') ||
combined.contains('too many requests')) {
hasRateLimit = true;
}
// Check for network errors
if (combined.contains('connection') ||
combined.contains('timeout') ||
combined.contains('network') ||
@@ -674,7 +652,6 @@ class _LogSummaryCard extends StatelessWidget {
hasNetworkError = true;
}
// Check for not found
if (combined.contains('not found') ||
combined.contains('no results') ||
combined.contains('could not find')) {
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
@@ -23,16 +24,13 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
final extState = ref.read(extensionProvider);
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
// Use saved priority if available, otherwise use default order
if (extState.metadataProviderPriority.isNotEmpty) {
_providers = List.from(extState.metadataProviderPriority);
// Add any new providers not in saved priority
for (final provider in allProviders) {
if (!_providers.contains(provider)) {
_providers.add(provider);
}
}
// Remove providers that no longer exist
_providers.removeWhere((p) => !allProviders.contains(p));
} else {
_providers = allProviders;
@@ -56,7 +54,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -81,7 +78,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: const Text('Save'),
child: Text(context.l10n.dialogSave),
),
],
flexibleSpace: LayoutBuilder(
@@ -96,7 +93,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Metadata Priority',
context.l10n.metadataProviderPriorityTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -108,13 +105,11 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
),
),
// Description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Drag to reorder metadata providers. The app will try providers '
'from top to bottom when searching for tracks and fetching metadata.',
context.l10n.metadataProviderPriorityDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -122,7 +117,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
),
),
// Provider list
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverReorderableList(
@@ -150,7 +144,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
),
),
// Info section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -166,8 +159,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
const SizedBox(width: 12),
Expanded(
child: Text(
'Deezer has no rate limits and is recommended as primary. '
'Spotify may rate limit after many requests.',
context.l10n.metadataProviderPriorityInfo,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -190,16 +182,16 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard Changes?'),
content: const Text('You have unsaved changes. Do you want to discard them?'),
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
child: Text(context.l10n.dialogDiscard),
),
],
),
@@ -214,7 +206,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Metadata provider priority saved')),
SnackBar(content: Text(context.l10n.snackbarMetadataProviderSaved)),
);
}
}
@@ -246,7 +238,7 @@ class _MetadataProviderItem extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
final info = _getProviderInfo(provider);
final info = _getProviderInfo(context, provider);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
@@ -259,7 +251,6 @@ class _MetadataProviderItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Priority number
Container(
width: 28,
height: 28,
@@ -282,7 +273,6 @@ class _MetadataProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Provider icon
Icon(
info.icon,
color: info.isBuiltIn
@@ -290,7 +280,6 @@ class _MetadataProviderItem extends StatelessWidget {
: colorScheme.secondary,
),
const SizedBox(width: 12),
// Provider name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -310,7 +299,6 @@ class _MetadataProviderItem extends StatelessWidget {
],
),
),
// Drag handle
Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
@@ -323,28 +311,27 @@ class _MetadataProviderItem extends StatelessWidget {
);
}
_MetadataProviderInfo _getProviderInfo(String provider) {
_MetadataProviderInfo _getProviderInfo(BuildContext context, String provider) {
switch (provider) {
case 'deezer':
return _MetadataProviderInfo(
name: 'Deezer',
icon: Icons.album,
description: 'No rate limits',
description: context.l10n.metadataNoRateLimits,
isBuiltIn: true,
);
case 'spotify':
return _MetadataProviderInfo(
name: 'Spotify',
icon: Icons.music_note,
description: 'May rate limit',
description: context.l10n.metadataMayRateLimit,
isBuiltIn: true,
);
default:
// Extension provider
return _MetadataProviderInfo(
name: provider,
icon: Icons.extension,
description: 'Extension',
description: context.l10n.providerExtension,
isBuiltIn: false,
);
}
+106 -146
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -18,53 +19,51 @@ class OptionsSettingsPage extends ConsumerWidget {
final topPadding = MediaQuery.of(context).padding.top;
return PopScope(
canPop: true,
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
'Options',
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.optionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Search Source section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Search Source'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -76,7 +75,6 @@ class OptionsSettingsPage extends ConsumerWidget {
.setMetadataSource(v),
),
if (settings.metadataSource == 'spotify') ...[
// Info card about Spotify credentials requirement
if (settings.spotifyClientId.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -93,7 +91,7 @@ class OptionsSettingsPage extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'Spotify requires your own API credentials. Get them free from developer.spotify.com',
context.l10n.optionsSpotifyWarning,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
fontSize: 12,
@@ -107,10 +105,10 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsItem(
icon: Icons.key,
title: 'Spotify Credentials',
title: context.l10n.optionsSpotifyCredentials,
subtitle: settings.spotifyClientId.isNotEmpty
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
: 'Required - tap to configure',
? context.l10n.optionsSpotifyCredentialsConfigured(settings.spotifyClientId.length > 8 ? settings.spotifyClientId.substring(0, 8) : settings.spotifyClientId)
: context.l10n.optionsSpotifyCredentialsRequired,
onTap: () =>
_showSpotifyCredentialsDialog(context, ref, settings),
trailing: Icon(
@@ -129,17 +127,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Download options section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Download'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.sync,
title: 'Auto Fallback',
subtitle: 'Try other services if download fails',
title: context.l10n.optionsAutoFallback,
subtitle: context.l10n.optionsAutoFallbackSubtitle,
value: settings.autoFallback,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setAutoFallback(v),
@@ -147,10 +144,10 @@ class OptionsSettingsPage extends ConsumerWidget {
if (hasExtensions)
SettingsSwitchItem(
icon: Icons.extension,
title: 'Use Extension Providers',
title: context.l10n.optionsUseExtensionProviders,
subtitle: settings.useExtensionProviders
? 'Extensions will be tried first'
: 'Using built-in providers only',
? context.l10n.optionsUseExtensionProvidersOn
: context.l10n.optionsUseExtensionProvidersOff,
value: settings.useExtensionProviders,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -158,16 +155,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsSwitchItem(
icon: Icons.lyrics,
title: 'Embed Lyrics',
subtitle: 'Embed synced lyrics into FLAC files',
title: context.l10n.optionsEmbedLyrics,
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
value: settings.embedLyrics,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
),
SettingsSwitchItem(
icon: Icons.image,
title: 'Max Quality Cover',
subtitle: 'Download highest resolution cover art',
title: context.l10n.optionsMaxQualityCover,
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
value: settings.maxQualityCover,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -178,9 +175,8 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Performance section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Performance'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -195,17 +191,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// App section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.store,
title: 'Extension Store',
subtitle: 'Show Store tab in navigation',
title: context.l10n.optionsExtensionStore,
subtitle: context.l10n.optionsExtensionStoreSubtitle,
value: settings.showExtensionStore,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -213,8 +208,8 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SettingsSwitchItem(
icon: Icons.system_update,
title: 'Check for Updates',
subtitle: 'Notify when new version is available',
title: context.l10n.optionsCheckUpdates,
subtitle: context.l10n.optionsCheckUpdatesSubtitle,
value: settings.checkForUpdates,
onChanged: (v) => ref
.read(settingsProvider.notifier)
@@ -229,17 +224,16 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Data section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Data'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionData),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.delete_forever,
title: 'Clear Download History',
subtitle: 'Remove all downloaded tracks from history',
title: context.l10n.optionsClearHistory,
subtitle: context.l10n.optionsClearHistorySubtitle,
onTap: () =>
_showClearHistoryDialog(context, ref, colorScheme),
showDivider: false,
@@ -248,19 +242,18 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
// Debug section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Debug'),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.bug_report,
title: 'Detailed Logging',
title: context.l10n.optionsDetailedLogging,
subtitle: settings.enableLogging
? 'Detailed logs are being recorded'
: 'Enable for bug reports',
? context.l10n.optionsDetailedLoggingOn
: context.l10n.optionsDetailedLoggingOff,
value: settings.enableLogging,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setEnableLogging(v),
@@ -285,14 +278,14 @@ class OptionsSettingsPage extends ConsumerWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear History'),
content: const Text(
'Are you sure you want to clear all download history? This cannot be undone.',
title: Text(context.l10n.dialogClearHistoryTitle),
content: Text(
context.l10n.dialogClearHistoryMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
@@ -300,9 +293,9 @@ class OptionsSettingsPage extends ConsumerWidget {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('History cleared')));
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarHistoryCleared)));
},
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
@@ -353,7 +346,7 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
Text(
'Spotify Credentials',
context.l10n.credentialsTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -361,7 +354,7 @@ class OptionsSettingsPage extends ConsumerWidget {
),
const SizedBox(height: 8),
Text(
'Enter your Client ID and Secret to use your own Spotify application quota.',
context.l10n.credentialsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -369,12 +362,11 @@ class OptionsSettingsPage extends ConsumerWidget {
),
const SizedBox(height: 32),
// Client ID
TextField(
controller: clientIdController,
decoration: InputDecoration(
labelText: 'Client ID',
hintText: 'Paste Client ID',
labelText: context.l10n.credentialsClientId,
hintText: context.l10n.credentialsClientIdHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
@@ -407,13 +399,12 @@ class OptionsSettingsPage extends ConsumerWidget {
),
const SizedBox(height: 16),
// Client Secret
TextField(
controller: clientSecretController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Client Secret',
hintText: 'Paste Client Secret',
labelText: context.l10n.credentialsClientSecret,
hintText: context.l10n.credentialsClientSecretHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
@@ -458,12 +449,12 @@ class OptionsSettingsPage extends ConsumerWidget {
.setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials saved')),
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please fill all fields'),
SnackBar(
content: Text(context.l10n.snackbarFillAllFields),
),
);
}
@@ -474,9 +465,9 @@ class OptionsSettingsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(16),
),
),
child: const Text(
'Save Credentials',
style: TextStyle(fontWeight: FontWeight.bold),
child: Text(
context.l10n.actionSaveCredentials,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
@@ -489,14 +480,14 @@ class OptionsSettingsPage extends ConsumerWidget {
.clearSpotifyCredentials();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Credentials cleared')),
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)),
);
},
style: TextButton.styleFrom(
foregroundColor: colorScheme.error,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Remove Credentials'),
child: Text(context.l10n.actionRemoveCredentials),
),
],
@@ -540,14 +531,14 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Concurrent Downloads',
context.l10n.optionsConcurrentDownloads,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
currentValue == 1
? 'Sequential (1 at a time)'
: '$currentValue parallel downloads',
? context.l10n.optionsConcurrentSequential
: context.l10n.optionsConcurrentParallel(currentValue),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -590,7 +581,7 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting',
context.l10n.optionsConcurrentWarning,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
@@ -682,14 +673,14 @@ class _UpdateChannelSelector extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Update Channel',
context.l10n.optionsUpdateChannel,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
currentChannel == 'preview'
? 'Get preview releases'
: 'Stable releases only',
? context.l10n.optionsUpdateChannelPreview
: context.l10n.optionsUpdateChannelStable,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -703,13 +694,13 @@ class _UpdateChannelSelector extends StatelessWidget {
Row(
children: [
_ChannelChip(
label: 'Stable',
label: context.l10n.channelStable,
isSelected: currentChannel == 'stable',
onTap: () => onChanged('stable'),
),
const SizedBox(width: 8),
_ChannelChip(
label: 'Preview',
label: context.l10n.channelPreview,
isSelected: currentChannel == 'preview',
onTap: () => onChanged('preview'),
),
@@ -726,7 +717,7 @@ class _UpdateChannelSelector extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Preview may contain bugs or incomplete features',
context.l10n.optionsUpdateChannelWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -803,7 +794,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
// Check if extension search provider is active AND enabled
Extension? activeExtension;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
activeExtension = extState.extensions
@@ -823,7 +813,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Primary Provider',
context.l10n.optionsPrimaryProvider,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
@@ -831,8 +821,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(height: 4),
Text(
hasExtensionSearch
? 'Using extension: $extensionName'
: 'Service used when searching by track name.',
? context.l10n.optionsUsingExtension(extensionName!)
: context.l10n.optionsPrimaryProviderSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: hasExtensionSearch
? colorScheme.primary
@@ -845,12 +835,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip(
icon: Icons.graphic_eq,
label: 'Deezer',
badge: 'Free',
badgeColor: colorScheme.tertiary,
// Not selected if extension is active
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
onTap: () {
// If extension was active, reset it to default
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
@@ -861,12 +847,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip(
icon: Icons.music_note,
label: 'Spotify',
badge: 'API Key',
badgeColor: colorScheme.secondary,
// Not selected if extension is active
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
onTap: () {
// If extension was active, reset it to default
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
@@ -887,7 +869,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Tap Deezer or Spotify to switch back from extension',
context.l10n.optionsSwitchBack,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -907,16 +889,12 @@ class _SourceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback? onTap;
final String? badge;
final Color? badgeColor;
const _SourceChip({
required this.icon,
required this.label,
required this.isSelected,
this.onTap,
this.badge,
this.badgeColor,
});
@override
@@ -962,24 +940,6 @@ class _SourceChip extends StatelessWidget {
: colorScheme.onSurfaceVariant,
),
),
if (badge != null) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
badge!,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: badgeColor ?? colorScheme.tertiary,
),
),
),
],
],
),
),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget {
@@ -23,17 +24,13 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
final extState = ref.read(extensionProvider);
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
// Use saved priority if available, otherwise use default order
if (extState.providerPriority.isNotEmpty) {
// Start with saved priority
_providers = List.from(extState.providerPriority);
// Add any new providers not in saved priority
for (final provider in allProviders) {
if (!_providers.contains(provider)) {
_providers.add(provider);
}
}
// Remove providers that no longer exist
_providers.removeWhere((p) => !allProviders.contains(p));
} else {
_providers = allProviders;
@@ -57,7 +54,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
@@ -82,7 +78,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
if (_hasChanges)
TextButton(
onPressed: _saveChanges,
child: const Text('Save'),
child: Text(context.l10n.dialogSave),
),
],
flexibleSpace: LayoutBuilder(
@@ -97,7 +93,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Provider Priority',
context.l10n.providerPriorityTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
@@ -109,13 +105,11 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
),
// Description
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Drag to reorder download providers. The app will try providers '
'from top to bottom when downloading tracks.',
context.l10n.providerPriorityDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -123,7 +117,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
),
// Provider list
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverReorderableList(
@@ -151,7 +144,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
),
// Info section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -167,8 +159,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
const SizedBox(width: 12),
Expanded(
child: Text(
'If a track is not available on the first provider, '
'the app will automatically try the next one.',
context.l10n.providerPriorityInfo,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
@@ -191,16 +182,16 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard Changes?'),
content: const Text('You have unsaved changes. Do you want to discard them?'),
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.dialogUnsavedChanges),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
child: Text(context.l10n.dialogDiscard),
),
],
),
@@ -215,7 +206,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Provider priority saved')),
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
);
}
}
@@ -247,7 +238,6 @@ class _ProviderItem extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
// Get provider info
final info = _getProviderInfo(provider);
return Padding(
@@ -261,7 +251,6 @@ class _ProviderItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Priority number
Container(
width: 28,
height: 28,
@@ -284,7 +273,6 @@ class _ProviderItem extends StatelessWidget {
),
),
const SizedBox(width: 16),
// Provider icon
Icon(
info.icon,
color: info.isBuiltIn
@@ -292,7 +280,6 @@ class _ProviderItem extends StatelessWidget {
: colorScheme.secondary,
),
const SizedBox(width: 12),
// Provider name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -304,7 +291,7 @@ class _ProviderItem extends StatelessWidget {
),
),
Text(
info.isBuiltIn ? 'Built-in' : 'Extension',
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -312,7 +299,6 @@ class _ProviderItem extends StatelessWidget {
],
),
),
// Drag handle
Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
@@ -346,7 +332,6 @@ class _ProviderItem extends StatelessWidget {
isBuiltIn: true,
);
default:
// Extension provider
return _ProviderInfo(
name: provider,
icon: Icons.extension,

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