diff --git a/.github/ISSUE_TEMPLATE/extension_feature_request.yml b/.github/ISSUE_TEMPLATE/extension_feature_request.yml index ffd55baf..8748a780 100644 --- a/.github/ISSUE_TEMPLATE/extension_feature_request.yml +++ b/.github/ISSUE_TEMPLATE/extension_feature_request.yml @@ -1,5 +1,5 @@ -name: Extension API Feature Request (Alpha) -description: Request new API features or capabilities for extension development (Extension system is in alpha) +name: Extension API Feature Request +description: Request new API features or capabilities for extension development title: "[Extension API]: " labels: ["enhancement", "extension-api"] body: @@ -15,7 +15,7 @@ body: label: Checklist description: Please confirm the following before submitting options: - - label: I have read the [Extension Development Guide](https://zarz.moe/docs) + - label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md) required: true - label: I have searched existing issues and this API feature hasn't been requested yet required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 558361b6..d79b15f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 0125bc7a..d6952219 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ Thumbs.db # Reference folder (development only) referensi/ -# Documentation (hosted separately) +# Documentation (development only, published separately) docs/ # Old spotiflac_android folder (moved to root) @@ -53,3 +53,9 @@ ios/.symlinks/ ios/Flutter/Flutter.framework/ ios/Flutter/Flutter.podspec android/app/libs/gobackend-sources.jar + +# Extension folder +extension/ + +# Agent instructions +AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0292bd..9bdab91a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,343 @@ # Changelog -## [2.2.9] - 2026-01-12 +## [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 + +#### Spotify Web Extension + +- Available in Extension Store - install and enable in Settings > Extensions +- Metadata provider using Spotify's internal web player API +- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists +- Useful when official Spotify 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 Spotify/Deezer metadata + - Toggle in Settings > Download > Separate Singles Folder + +- **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 Deezer/Spotify 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 Spotify 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/Spotify 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 + - One-tap install and update + - Offline cache for browsing without internet + - 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 + - SpotiFLAC automatically routes matching URLs to the appropriate extension + - Supports share intents and paste from clipboard + +- **Artist URL Handler Support**: Extensions can now return artist data from URL handlers + + - Added `type: "artist"` handling in track_provider.dart + - Navigate to artist screen with albums list from extension + +- **HMAC-SHA1 Utility**: New `utils.hmacSHA1(key, message)` function for extensions + - Enables TOTP generation and other cryptographic operations + - Returns byte array for flexible use + +### Fixed + +- **Extension Store Refresh**: Store tab now properly refreshes after uninstalling an extension + - "Installed" badge correctly updates to "Install" button + +### Documentation + +- Updated `docs/EXTENSION_DEVELOPMENT.md`: + - Added Custom URL Handler section with examples + - Added `handleUrl` function documentation + - Added URL pattern examples for YouTube, SoundCloud, Bandcamp + - Added `utils.hmacSHA1` documentation with TOTP example + +### Extensions + +- **Spotify Web Extension** (example): New extension for Spotify 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) + +--- + +## [3.0.0-alpha.3] - 2026-01-12 + +### Added + +- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders + - Based on `album_type` from Spotify/Deezer metadata + - Toggle in Settings > Download > Separate Singles Folder + - Singles saved to `{output}/Singles/`, albums to `{output}/Albums/` +- **Browser-like Polyfills**: New global APIs for easier library porting + - `fetch()` - Browser-compatible HTTP API with `json()`, `text()`, `arrayBuffer()` methods + - `atob()` / `btoa()` - Global Base64 encoding/decoding + - `TextEncoder` / `TextDecoder` - UTF-8 text encoding classes + - `URL` / `URLSearchParams` - URL parsing and manipulation classes + - Makes porting browser libraries (like `youtubei.js`) much easier + +### Performance + +- **Parallel API Calls**: Download URL fetching now uses parallel requests + - Tidal: All 8 APIs requested simultaneously, first success wins + - Qobuz: Both APIs requested simultaneously, first success wins + - Significantly reduces download URL fetch time ### Fixed @@ -8,6 +345,9 @@ - Detects existing entries by Spotify ID, Deezer ID, or ISRC - Replaces existing entry and moves to top of list - Auto-deduplicates existing history on app load +- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search + - Now checks if extension is still enabled before calling custom search + - Auto-resets search provider to default if extension was disabled - **Permission Error Message**: Fixed download showing "Song not found" when actually a permission error - Now shows proper message: "Cannot write to folder, check storage permission" - Added `permission` error type detection in backend @@ -19,6 +359,135 @@ --- +## [3.0.0-alpha.2] - 2026-01-12 + +### Added + +- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs + - `http.put(url, body, headers)` - PUT requests + - `http.delete(url, headers)` - DELETE requests + - `http.patch(url, body, headers)` - PATCH requests + - `http.clearCookies()` - Clear all cookies for the extension +- **Persistent Cookie Jar**: Each extension now has its own cookie jar + - Cookies automatically stored from `Set-Cookie` headers + - Cookies automatically sent with subsequent requests to same domain + - Useful for APIs requiring session cookies (YouTube, etc.) +- **Multi-Value Header Support**: Response headers now return arrays for multi-value headers + - `Set-Cookie` and other headers with multiple values returned as arrays + - Single-value headers still returned as strings for convenience +- **Generic HTTP Request Method**: New `http.request()` for full HTTP control + - Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.) + - Single options object for cleaner API: `http.request(url, { method, body, headers })` +- **Response Helper Properties**: HTTP responses now include convenience properties + - `response.ok` - true if status code is 2xx + - `response.status` - alias for `statusCode` + +### Fixed + +- **User-Agent Header Respect**: Custom `User-Agent` headers are now respected + - Previously, extension-provided User-Agent was overwritten + - Now only sets default User-Agent if extension doesn't provide one +- **HTTP POST Body Auto-Stringify**: `http.post()` now automatically stringifies objects to JSON + - Previously, passing an object as body resulted in `[object Object]` + - Now objects and arrays are automatically JSON.stringify'd + - String bodies still work as before (no double-encoding) + +### Documentation + +- Updated `docs/EXTENSION_DEVELOPMENT.md`: + - Added complete HTTP API documentation with all methods + - Added Cookie Jar documentation + - Added `http.put()`, `http.delete()`, `http.patch()`, `http.clearCookies()` docs + - Added YouTube Music / Innertube API example with custom User-Agent + - Added common domain lists for YouTube, SoundCloud, Bandcamp + - Improved HTTP API documentation with response properties + +--- + +## [3.0.0-alpha.1] - 2026-01-11 + +#### Extension System + +- **Custom Search Providers**: Extensions can now provide custom search functionality + - YouTube, SoundCloud, and other platforms via extensions + - Custom search placeholder text per extension + - Configurable thumbnail aspect ratios (square, wide, portrait) +- **Extension Upgrade System**: Upgrade extensions without losing data + - Preserves extension settings and cached data during upgrades + - Version comparison prevents downgrades + - Auto-detects upgrades when installing same extension +- **Custom Thumbnail Ratios**: Extensions can specify thumbnail display format + - `"square"` (1:1) - Album art style (default) + - `"wide"` (16:9) - YouTube/video style + - `"portrait"` (2:3) - Poster style + - Custom width/height override available + +### Added + +- **Track Source Tracking**: Tracks now remember which extension provided them + - `Track.source` field stores extension ID + - `TrackState.searchExtensionId` for current search context + - Enables extension-specific UI customization +- **Extension Upgrade API**: New methods for extension management + - `upgradeExtension(filePath)` - Upgrade existing extension + - `checkExtensionUpgrade(filePath)` - Check if file is an upgrade + - `RemoveExtensionByID` - Remove extension by ID +- **iOS Extension Support**: Added missing iOS method handlers + - `upgradeExtension` - Upgrade extension from file + - `checkExtensionUpgrade` - Check upgrade compatibility +- **Extension Documentation**: Comprehensive extension development guide + - Thumbnail ratio customization documentation + - Extension upgrade workflow documentation + - New troubleshooting entries for common issues + +### Changed + +- **Version Bump**: 2.2.7 → 3.0.0-alpha.1 (major version for extension system) +- **Build Number**: 49 → 50 +- **Extension Manager**: Improved upgrade detection in `LoadExtensionFromFile` + - Auto-detects if installing same extension with higher version + - Calls `UpgradeExtension` automatically for seamless upgrades + +### Fixed + +- **Extension `registerExtension`**: Fixed global `extension` variable not being set + - Extensions can now access their own functions via `extension.functionName()` + - Required for `customSearch` and other provider functions +- **Custom Search Empty Results**: Fixed error when extension returns null + - Now returns empty array instead of error + - Prevents crash when no results found +- **Mutex Crash on Upgrade**: Fixed "Unlock of unlocked RWMutex" crash + - Removed `defer m.mu.Unlock()` when manual unlock is used + - Proper lock handling in upgrade flow +- **Duplicate Error Messages**: Fixed extension install errors showing twice + - Added `clearError()` method to extension provider + - Improved PlatformException parsing to remove "null, null" artifacts +- **Extension Images Field**: Fixed thumbnails not showing in search results + - Added `Images` field to `ExtTrackMetadata` struct + - Renamed `GetCoverURL` to `ResolvedCoverURL` (gomobile conflict) + +### Technical + +- **Go Backend Changes**: + - `go_backend/extension_manager.go`: Added `compareVersions()`, `UpgradeExtension()`, `CheckExtensionUpgradeJSON()` + - `go_backend/extension_providers.go`: Added `Images` field, `ResolvedCoverURL()` method + - `go_backend/extension_manifest.go`: Added `ThumbnailRatio`, `ThumbnailWidth`, `ThumbnailHeight` to `SearchBehaviorConfig` + - `go_backend/exports.go`: Added `RemoveExtensionByID`, `UpgradeExtensionFromPath`, `CheckExtensionUpgradeFromPath` +- **Flutter Changes**: + - `lib/models/track.dart`: Added `source` field + - `lib/models/track.g.dart`: Updated for `source` field + - `lib/providers/track_provider.dart`: Added `searchExtensionId`, updated `_parseSearchTrack` with source parameter + - `lib/providers/extension_provider.dart`: Added `SearchBehavior.getThumbnailSize()`, `clearError()` + - `lib/screens/home_tab.dart`: Dynamic thumbnail size based on extension config + - `lib/screens/settings/extensions_page.dart`: Improved error handling + - `lib/services/platform_bridge.dart`: Added `upgradeExtension()`, `checkExtensionUpgrade()`, `removeExtension()` +- **iOS Changes**: + - `ios/Runner/AppDelegate.swift`: Added `upgradeExtension`, `checkExtensionUpgrade` handlers +- **Android Changes**: + - `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods + +--- + ## [2.2.8] - 2026-01-12 ### Added @@ -43,6 +512,8 @@ - **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)" +--- + ## [2.2.7] - 2026-01-11 ### Added diff --git a/README.md b/README.md index 9bcecccd..36c73c22 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,20 @@ To use Spotify as your search source without hitting rate limits: 4. Enter your Client ID and Secret 5. Change **Search Source** to Spotify +## Extensions + +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. 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](https://zarz.moe/docs) for complete documentation. + ## Other project ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) @@ -49,8 +63,6 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma ## 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. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index fb87483d..6a0fb6b5 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -218,6 +218,12 @@ class MainActivity: FlutterActivity() { } result.success(null) } + "hasSpotifyCredentials" -> { + val hasCredentials = withContext(Dispatchers.IO) { + Gobackend.checkSpotifyCredentials() + } + result.success(hasCredentials) + } "preWarmTrackCache" -> { val tracksJson = call.argument("tracks") ?: "[]" withContext(Dispatchers.IO) { @@ -317,6 +323,313 @@ class MainActivity: FlutterActivity() { } result.success(null) } + // Extension System methods + "initExtensionSystem" -> { + val extensionsDir = call.argument("extensions_dir") ?: "" + val dataDir = call.argument("data_dir") ?: "" + withContext(Dispatchers.IO) { + Gobackend.initExtensionSystem(extensionsDir, dataDir) + } + result.success(null) + } + "loadExtensionsFromDir" -> { + val dirPath = call.argument("dir_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.loadExtensionsFromDir(dirPath) + } + result.success(response) + } + "loadExtensionFromPath" -> { + val filePath = call.argument("file_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.loadExtensionFromPath(filePath) + } + result.success(response) + } + "unloadExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.unloadExtensionByID(extensionId) + } + result.success(null) + } + "removeExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.removeExtensionByID(extensionId) + } + result.success(null) + } + "upgradeExtension" -> { + val filePath = call.argument("file_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.upgradeExtensionFromPath(filePath) + } + result.success(response) + } + "checkExtensionUpgrade" -> { + val filePath = call.argument("file_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.checkExtensionUpgradeFromPath(filePath) + } + result.success(response) + } + "getInstalledExtensions" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getInstalledExtensions() + } + result.success(response) + } + "setExtensionEnabled" -> { + val extensionId = call.argument("extension_id") ?: "" + val enabled = call.argument("enabled") ?: false + withContext(Dispatchers.IO) { + Gobackend.setExtensionEnabledByID(extensionId, enabled) + } + result.success(null) + } + "setProviderPriority" -> { + val priorityJson = call.argument("priority") ?: "[]" + withContext(Dispatchers.IO) { + Gobackend.setProviderPriorityJSON(priorityJson) + } + result.success(null) + } + "getProviderPriority" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getProviderPriorityJSON() + } + result.success(response) + } + "setMetadataProviderPriority" -> { + val priorityJson = call.argument("priority") ?: "[]" + withContext(Dispatchers.IO) { + Gobackend.setMetadataProviderPriorityJSON(priorityJson) + } + result.success(null) + } + "getMetadataProviderPriority" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getMetadataProviderPriorityJSON() + } + result.success(response) + } + "getExtensionSettings" -> { + val extensionId = call.argument("extension_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getExtensionSettingsJSON(extensionId) + } + result.success(response) + } + "setExtensionSettings" -> { + val extensionId = call.argument("extension_id") ?: "" + val settingsJson = call.argument("settings") ?: "{}" + withContext(Dispatchers.IO) { + Gobackend.setExtensionSettingsJSON(extensionId, settingsJson) + } + result.success(null) + } + "searchTracksWithExtensions" -> { + val query = call.argument("query") ?: "" + val limit = call.argument("limit") ?: 20 + val response = withContext(Dispatchers.IO) { + Gobackend.searchTracksWithExtensionsJSON(query, limit.toLong()) + } + result.success(response) + } + "downloadWithExtensions" -> { + val requestJson = call.arguments as String + val response = withContext(Dispatchers.IO) { + Gobackend.downloadWithExtensionsJSON(requestJson) + } + result.success(response) + } + "removeExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.removeExtensionByID(extensionId) + } + result.success(null) + } + "cleanupExtensions" -> { + withContext(Dispatchers.IO) { + Gobackend.cleanupExtensions() + } + result.success(null) + } + // Extension Auth API methods + "getExtensionPendingAuth" -> { + val extensionId = call.argument("extension_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getExtensionPendingAuthJSON(extensionId) + } + if (response.isNullOrEmpty()) { + result.success(null) + } else { + result.success(response) + } + } + "setExtensionAuthCode" -> { + val extensionId = call.argument("extension_id") ?: "" + val authCode = call.argument("auth_code") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setExtensionAuthCodeByID(extensionId, authCode) + } + result.success(null) + } + "setExtensionTokens" -> { + val extensionId = call.argument("extension_id") ?: "" + val accessToken = call.argument("access_token") ?: "" + val refreshToken = call.argument("refresh_token") ?: "" + val expiresIn = call.argument("expires_in") ?: 0 + withContext(Dispatchers.IO) { + Gobackend.setExtensionTokensByID(extensionId, accessToken, refreshToken, expiresIn.toLong()) + } + result.success(null) + } + "clearExtensionPendingAuth" -> { + val extensionId = call.argument("extension_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.clearExtensionPendingAuthByID(extensionId) + } + result.success(null) + } + "isExtensionAuthenticated" -> { + val extensionId = call.argument("extension_id") ?: "" + val isAuth = withContext(Dispatchers.IO) { + Gobackend.isExtensionAuthenticatedByID(extensionId) + } + result.success(isAuth) + } + "getAllPendingAuthRequests" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getAllPendingAuthRequestsJSON() + } + result.success(response) + } + // Extension FFmpeg API + "getPendingFFmpegCommand" -> { + val commandId = call.argument("command_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getPendingFFmpegCommandJSON(commandId) + } + if (response.isNullOrEmpty()) { + result.success(null) + } else { + result.success(response) + } + } + "setFFmpegCommandResult" -> { + val commandId = call.argument("command_id") ?: "" + val success = call.argument("success") ?: false + val output = call.argument("output") ?: "" + val error = call.argument("error") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setFFmpegCommandResultByID(commandId, success, output, error) + } + result.success(null) + } + "getAllPendingFFmpegCommands" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getAllPendingFFmpegCommandsJSON() + } + result.success(response) + } + // Extension Custom Search API + "customSearchWithExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + val query = call.argument("query") ?: "" + val optionsJson = call.argument("options") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson) + } + result.success(response) + } + "getSearchProviders" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getSearchProvidersJSON() + } + result.success(response) + } + // Extension URL Handler API + "handleURLWithExtension" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.handleURLWithExtensionJSON(url) + } + result.success(response) + } + "findURLHandler" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.findURLHandlerJSON(url) + } + result.success(response) + } + "getURLHandlers" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getURLHandlersJSON() + } + result.success(response) + } + // Extension Post-Processing API + "runPostProcessing" -> { + val filePath = call.argument("file_path") ?: "" + val metadataJson = call.argument("metadata") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.runPostProcessingJSON(filePath, metadataJson) + } + result.success(response) + } + "getPostProcessingProviders" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getPostProcessingProvidersJSON() + } + result.success(response) + } + // Extension Store + "initExtensionStore" -> { + val cacheDir = call.argument("cache_dir") ?: "" + withContext(Dispatchers.IO) { + Gobackend.initExtensionStoreJSON(cacheDir) + } + result.success(null) + } + "getStoreExtensions" -> { + val forceRefresh = call.argument("force_refresh") ?: false + val response = withContext(Dispatchers.IO) { + Gobackend.getStoreExtensionsJSON(forceRefresh) + } + result.success(response) + } + "searchStoreExtensions" -> { + val query = call.argument("query") ?: "" + val category = call.argument("category") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.searchStoreExtensionsJSON(query, category) + } + result.success(response) + } + "getStoreCategories" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getStoreCategoriesJSON() + } + result.success(response) + } + "downloadStoreExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + val destDir = call.argument("dest_dir") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.downloadStoreExtensionJSON(extensionId, destDir) + } + result.success(response) + } + "clearStoreCache" -> { + withContext(Dispatchers.IO) { + Gobackend.clearStoreCacheJSON() + } + result.success(null) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/amazon.go b/go_backend/amazon.go index e8db3cb3..35860f5f 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -173,7 +173,7 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string { // downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC) // This uses submit → poll → download mechanism // Internal function - not exported to gomobile -func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) { +func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) { var lastError error for _, region := range a.regions { diff --git a/go_backend/cover.go b/go_backend/cover.go index 36c83a90..af43d754 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -9,10 +9,20 @@ import ( // 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) ) +// 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,17 +30,27 @@ 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) - // Upgrade to max quality if requested - downloadURL := coverURL + // First upgrade small (300) to medium (640) - always do this + downloadURL := convertSmallToMedium(coverURL) + if downloadURL != coverURL { + GoLog("[Cover] Upgraded 300x300 → 640x640") + } + + // Then upgrade to max quality if requested if maxQuality { - 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 + GoLog("[Cover] Upgraded to max resolution (~2000x2000)") + } else { + GoLog("[Cover] Max resolution not available, using 640x640") } } + GoLog("[Cover] Final URL: %s", downloadURL) + client := NewHTTPClientWithTimeout(DefaultTimeout) // Create request with User-Agent (required by Spotify CDN) @@ -54,12 +74,25 @@ 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)) + // Calculate approximate resolution from file size + // JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB + sizeKB := len(data) / 1024 + var resolution string + if sizeKB > 200 { + 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 +// Same logic as PC version - directly replaces 640x640 size code with max resolution +// No HEAD verification needed - Spotify CDN always serves max resolution if available func upgradeToMaxQuality(coverURL string) string { // Spotify image URLs can be upgraded by changing the size parameter // Format: https://i.scdn.co/image/ab67616d0000b273... @@ -67,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string { // ab67616d000082c1 = Max resolution (~2000x2000) if strings.Contains(coverURL, spotifySize640) { - // Try max resolution first - maxURL := 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 - } - } - } + return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) } return coverURL @@ -93,9 +112,12 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string { return "" } + // Always upgrade small to medium first + result := convertSmallToMedium(imageURL) + if maxQuality { - return upgradeToMaxQuality(imageURL) + result = upgradeToMaxQuality(result) } - return imageURL + return result } diff --git a/go_backend/deezer.go b/go_backend/deezer.go index dce19752..42231e26 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -146,6 +146,7 @@ type deezerAlbumFull struct { CoverXL string `json:"cover_xl"` ReleaseDate string `json:"release_date"` NbTracks int `json:"nb_tracks"` + RecordType string `json:"record_type"` // album, single, ep, compile Artist deezerArtist `json:"artist"` Contributors []deezerArtist `json:"contributors"` Tracks struct { @@ -326,6 +327,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data) tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) + // Normalize record_type (Deezer uses "compile" instead of "compilation") + albumType := album.RecordType + if albumType == "compile" { + albumType = "compilation" + } + for _, track := range album.Tracks.Data { trackIDStr := fmt.Sprintf("%d", track.ID) isrc := isrcMap[trackIDStr] @@ -345,6 +352,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp ExternalURL: track.Link, ISRC: isrc, AlbumID: fmt.Sprintf("deezer:%d", album.ID), + AlbumType: albumType, }) } diff --git a/go_backend/exports.go b/go_backend/exports.go index 1d0c822a..d9cde03b 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -32,18 +32,26 @@ func ParseSpotifyURL(url string) (string, error) { } // SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter -// Pass empty strings to use default credentials func SetSpotifyAPICredentials(clientID, clientSecret string) { SetSpotifyCredentials(clientID, clientSecret) } +// CheckSpotifyCredentials checks if Spotify credentials are configured +// Returns true if credentials are available (custom or env vars) +func CheckSpotifyCredentials() bool { + return HasSpotifyCredentials() +} + // GetSpotifyMetadata fetches metadata from Spotify URL // Returns JSON with track/album/playlist data func GetSpotifyMetadata(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - client := NewSpotifyMetadataClient() + client, err := NewSpotifyMetadataClient() + if err != nil { + return "", err + } data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) if err != nil { return "", err @@ -63,7 +71,10 @@ func SearchSpotify(query string, limit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - client := NewSpotifyMetadataClient() + client, err := NewSpotifyMetadataClient() + if err != nil { + return "", err + } results, err := client.SearchTracks(ctx, query, limit) if err != nil { return "", err @@ -83,7 +94,10 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - client := NewSpotifyMetadataClient() + client, err := NewSpotifyMetadataClient() + if err != nil { + return "", err + } results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) if err != nil { return "", err @@ -135,6 +149,7 @@ type DownloadRequest struct { ReleaseDate string `json:"release_date"` 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) } // DownloadResponse represents the result of a download @@ -152,10 +167,14 @@ type DownloadResponse struct { Title string `json:"title,omitempty"` Artist string `json:"artist,omitempty"` Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` ReleaseDate string `json:"release_date,omitempty"` TrackNumber int `json:"track_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"` ISRC string `json:"isrc,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + // If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata) + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` } // DownloadResult is a generic result type for all downloaders @@ -189,6 +208,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 @@ -326,6 +350,11 @@ func DownloadWithFallback(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) + } + // Build service order starting with preferred service allServices := []string{"tidal", "qobuz", "amazon"} preferredService := req.Service @@ -888,21 +917,26 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { defer cancel() // Try Spotify first - client := NewSpotifyMetadataClient() - data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) - if err == nil { - jsonBytes, err := json.Marshal(data) - if err != nil { + 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) + if err == nil { + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + 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 } - 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 @@ -1022,3 +1056,804 @@ func errorResponse(msg string) (string, error) { jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } + +// ==================== EXTENSION SYSTEM ==================== + +// InitExtensionSystem initializes the extension system with directories +func InitExtensionSystem(extensionsDir, dataDir string) error { + manager := GetExtensionManager() + if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { + return err + } + + settingsStore := GetExtensionSettingsStore() + if err := settingsStore.SetDataDir(dataDir); err != nil { + return err + } + + return nil +} + +// LoadExtensionsFromDir loads all extensions from a directory +func LoadExtensionsFromDir(dirPath string) (string, error) { + manager := GetExtensionManager() + loaded, errors := manager.LoadExtensionsFromDirectory(dirPath) + + result := map[string]interface{}{ + "loaded": loaded, + "errors": make([]string, len(errors)), + } + + for i, err := range errors { + result["errors"].([]string)[i] = err.Error() + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// LoadExtensionFromPath loads a single extension from a .spotiflac-ext file +func LoadExtensionFromPath(filePath string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.LoadExtensionFromFile(filePath) + if err != nil { + return "", err + } + + // Initialize with saved settings + settingsStore := GetExtensionSettingsStore() + settings := settingsStore.GetAll(ext.ID) + if len(settings) > 0 { + manager.InitializeExtension(ext.ID, settings) + } + + result := map[string]interface{}{ + "id": ext.ID, + "name": ext.Manifest.Name, + "display_name": ext.Manifest.DisplayName, + "version": ext.Manifest.Version, + "enabled": ext.Enabled, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// UnloadExtensionByID unloads an extension +func UnloadExtensionByID(extensionID string) error { + manager := GetExtensionManager() + return manager.UnloadExtension(extensionID) +} + +// RemoveExtensionByID completely removes an extension (unload + delete files) +func RemoveExtensionByID(extensionID string) error { + manager := GetExtensionManager() + return manager.RemoveExtension(extensionID) +} + +// UpgradeExtensionFromPath upgrades an existing extension from a new package file +func UpgradeExtensionFromPath(filePath string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.UpgradeExtension(filePath) + if err != nil { + return "", err + } + + // Initialize with saved settings + settingsStore := GetExtensionSettingsStore() + settings := settingsStore.GetAll(ext.ID) + if len(settings) > 0 { + manager.InitializeExtension(ext.ID, settings) + } + + // Return extension info as JSON + result := map[string]interface{}{ + "id": ext.ID, + "display_name": ext.Manifest.DisplayName, + "version": ext.Manifest.Version, + "enabled": ext.Enabled, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// CheckExtensionUpgradeFromPath checks if a package file is an upgrade for an existing extension +func CheckExtensionUpgradeFromPath(filePath string) (string, error) { + manager := GetExtensionManager() + return manager.CheckExtensionUpgradeJSON(filePath) +} + +// GetInstalledExtensions returns all installed extensions as JSON +func GetInstalledExtensions() (string, error) { + manager := GetExtensionManager() + return manager.GetInstalledExtensionsJSON() +} + +// SetExtensionEnabledByID enables or disables an extension +func SetExtensionEnabledByID(extensionID string, enabled bool) error { + manager := GetExtensionManager() + return manager.SetExtensionEnabled(extensionID, enabled) +} + +// SetProviderPriorityJSON sets the provider priority order from JSON array +func SetProviderPriorityJSON(priorityJSON string) error { + var priority []string + if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { + return err + } + + SetProviderPriority(priority) + return nil +} + +// GetProviderPriorityJSON returns the provider priority order as JSON +func GetProviderPriorityJSON() (string, error) { + priority := GetProviderPriority() + jsonBytes, err := json.Marshal(priority) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +// SetMetadataProviderPriorityJSON sets the metadata provider priority order from JSON array +func SetMetadataProviderPriorityJSON(priorityJSON string) error { + var priority []string + if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { + return err + } + + SetMetadataProviderPriority(priority) + return nil +} + +// GetMetadataProviderPriorityJSON returns the metadata provider priority order as JSON +func GetMetadataProviderPriorityJSON() (string, error) { + priority := GetMetadataProviderPriority() + jsonBytes, err := json.Marshal(priority) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +// GetExtensionSettingsJSON returns settings for an extension as JSON +func GetExtensionSettingsJSON(extensionID string) (string, error) { + store := GetExtensionSettingsStore() + settings := store.GetAll(extensionID) + + jsonBytes, err := json.Marshal(settings) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SetExtensionSettingsJSON sets settings for an extension from JSON +func SetExtensionSettingsJSON(extensionID, settingsJSON string) error { + var settings map[string]interface{} + if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { + return err + } + + store := GetExtensionSettingsStore() + if err := store.SetAll(extensionID, settings); err != nil { + return err + } + + // Re-initialize extension with new settings + manager := GetExtensionManager() + return manager.InitializeExtension(extensionID, settings) +} + +// SearchTracksWithExtensionsJSON searches all extension metadata providers +func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { + manager := GetExtensionManager() + tracks, err := manager.SearchTracksWithExtensions(query, limit) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(tracks) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// DownloadWithExtensionsJSON downloads using extension providers with fallback +func DownloadWithExtensionsJSON(requestJSON string) (string, error) { + var req DownloadRequest + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return "", fmt.Errorf("invalid request: %w", err) + } + + result, err := DownloadWithExtensionFallback(req) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// CleanupExtensions unloads all extensions gracefully +func CleanupExtensions() { + manager := GetExtensionManager() + manager.UnloadAllExtensions() +} + +// ==================== EXTENSION AUTH API ==================== + +// GetExtensionPendingAuthJSON returns pending auth request for an extension +func GetExtensionPendingAuthJSON(extensionID string) (string, error) { + req := GetPendingAuthRequest(extensionID) + if req == nil { + return "", nil + } + + result := map[string]interface{}{ + "extension_id": req.ExtensionID, + "auth_url": req.AuthURL, + "callback_url": req.CallbackURL, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SetExtensionAuthCodeByID sets auth code for an extension (called from Flutter after OAuth callback) +func SetExtensionAuthCodeByID(extensionID, authCode string) { + SetExtensionAuthCode(extensionID, authCode) +} + +// SetExtensionTokensByID sets tokens for an extension +func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) { + var expiresAt time.Time + if expiresIn > 0 { + expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) + } + SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt) +} + +// ClearExtensionPendingAuthByID clears pending auth request for an extension +func ClearExtensionPendingAuthByID(extensionID string) { + ClearPendingAuthRequest(extensionID) +} + +// IsExtensionAuthenticatedByID checks if an extension is authenticated +func IsExtensionAuthenticatedByID(extensionID string) bool { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[extensionID] + if !exists { + return false + } + + // Check if token is expired + if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { + return false + } + + return state.IsAuthenticated +} + +// GetAllPendingAuthRequestsJSON returns all pending auth requests +func GetAllPendingAuthRequestsJSON() (string, error) { + pendingAuthRequestsMu.RLock() + defer pendingAuthRequestsMu.RUnlock() + + requests := make([]map[string]interface{}, 0, len(pendingAuthRequests)) + for _, req := range pendingAuthRequests { + requests = append(requests, map[string]interface{}{ + "extension_id": req.ExtensionID, + "auth_url": req.AuthURL, + "callback_url": req.CallbackURL, + }) + } + + jsonBytes, err := json.Marshal(requests) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ==================== EXTENSION FFMPEG API ==================== + +// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute +func GetPendingFFmpegCommandJSON(commandID string) (string, error) { + cmd := GetPendingFFmpegCommand(commandID) + if cmd == nil { + return "", nil + } + + result := map[string]interface{}{ + "command_id": commandID, + "extension_id": cmd.ExtensionID, + "command": cmd.Command, + "input_path": cmd.InputPath, + "output_path": cmd.OutputPath, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SetFFmpegCommandResultByID sets the result of an FFmpeg command +func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) { + SetFFmpegCommandResult(commandID, success, output, errorMsg) +} + +// GetAllPendingFFmpegCommandsJSON returns all pending FFmpeg commands +func GetAllPendingFFmpegCommandsJSON() (string, error) { + ffmpegCommandsMu.RLock() + defer ffmpegCommandsMu.RUnlock() + + commands := make([]map[string]interface{}, 0) + for cmdID, cmd := range ffmpegCommands { + if !cmd.Completed { + commands = append(commands, map[string]interface{}{ + "command_id": cmdID, + "extension_id": cmd.ExtensionID, + "command": cmd.Command, + }) + } + } + + jsonBytes, err := json.Marshal(commands) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ==================== 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() { + // Not a metadata provider, return original + 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 { + // Error enriching, return original + 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() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Manifest.HasCustomSearch() { + return "", fmt.Errorf("extension '%s' does not support custom search", extensionID) + } + + var options map[string]interface{} + if optionsJSON != "" { + if err := json.Unmarshal([]byte(optionsJSON), &options); err != nil { + options = make(map[string]interface{}) + } + } + + provider := NewExtensionProviderWrapper(ext) + tracks, err := provider.CustomSearch(query, options) + if err != nil { + 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{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.ResolvedCoverURL(), // Use helper to get cover URL from either field + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + } + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetSearchProvidersJSON returns all extensions that provide custom search +func GetSearchProvidersJSON() (string, error) { + manager := GetExtensionManager() + providers := manager.GetSearchProviders() + + result := make([]map[string]interface{}, 0, len(providers)) + for _, p := range providers { + result = append(result, map[string]interface{}{ + "id": p.extension.ID, + "display_name": p.extension.Manifest.DisplayName, + "placeholder": p.extension.Manifest.SearchBehavior.Placeholder, + "primary": p.extension.Manifest.SearchBehavior.Primary, + "icon": p.extension.Manifest.SearchBehavior.Icon, + }) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ==================== EXTENSION URL HANDLER ==================== + +// HandleURLWithExtensionJSON tries to handle a URL with any matching extension +// Returns JSON with type, tracks, album info, etc. +func HandleURLWithExtensionJSON(url string) (string, error) { + manager := GetExtensionManager() + resultWithID, err := manager.HandleURLWithExtension(url) + if err != nil { + return "", err + } + + 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, + "name": result.Name, + "cover_url": result.CoverURL, + } + + // Add track if single track + if result.Track != nil { + response["track"] = map[string]interface{}{ + "id": result.Track.ID, + "name": result.Track.Name, + "artists": result.Track.Artists, + "album_name": result.Track.AlbumName, + "album_artist": result.Track.AlbumArtist, + "duration_ms": result.Track.DurationMS, + "images": result.Track.ResolvedCoverURL(), + "release_date": result.Track.ReleaseDate, + "track_number": result.Track.TrackNumber, + "disc_number": result.Track.DiscNumber, + "isrc": result.Track.ISRC, + "provider_id": result.Track.ProviderID, + } + } + + // Add tracks if multiple + if len(result.Tracks) > 0 { + tracks := make([]map[string]interface{}, len(result.Tracks)) + for i, track := range result.Tracks { + 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, + "images": track.ResolvedCoverURL(), + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + } + } + response["tracks"] = tracks + } + + // Add album info if present + if result.Album != nil { + response["album"] = map[string]interface{}{ + "id": result.Album.ID, + "name": result.Album.Name, + "artists": result.Album.Artists, + "cover_url": result.Album.CoverURL, + "release_date": result.Album.ReleaseDate, + "total_tracks": result.Album.TotalTracks, + } + } + + // Add artist info if present + if result.Artist != nil { + artistResponse := map[string]interface{}{ + "id": result.Artist.ID, + "name": result.Artist.Name, + "image_url": result.Artist.ImageURL, + } + + // Add albums if present + if len(result.Artist.Albums) > 0 { + albums := make([]map[string]interface{}, len(result.Artist.Albums)) + for i, album := range result.Artist.Albums { + albumType := album.AlbumType + if albumType == "" { + albumType = "album" + } + albums[i] = map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "images": album.CoverURL, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": albumType, + } + } + artistResponse["albums"] = albums + } + + response["artist"] = artistResponse + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// FindURLHandlerJSON finds an extension that can handle the given URL +// Returns extension ID or empty string if none found +func FindURLHandlerJSON(url string) string { + manager := GetExtensionManager() + handler := manager.FindURLHandler(url) + if handler == nil { + return "" + } + return handler.extension.ID +} + +// GetURLHandlersJSON returns all extensions that handle custom URLs +func GetURLHandlersJSON() (string, error) { + manager := GetExtensionManager() + handlers := manager.GetURLHandlers() + + result := make([]map[string]interface{}, 0, len(handlers)) + for _, h := range handlers { + result = append(result, map[string]interface{}{ + "id": h.extension.ID, + "display_name": h.extension.Manifest.DisplayName, + "patterns": h.extension.Manifest.URLHandler.Patterns, + }) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ==================== EXTENSION POST-PROCESSING ==================== + +// RunPostProcessingJSON runs post-processing hooks on a file +func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { + var metadata map[string]interface{} + if metadataJSON != "" { + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + metadata = make(map[string]interface{}) + } + } + + manager := GetExtensionManager() + result, err := manager.RunPostProcessing(filePath, metadata) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetPostProcessingProvidersJSON returns all extensions that provide post-processing +func GetPostProcessingProvidersJSON() (string, error) { + manager := GetExtensionManager() + providers := manager.GetPostProcessingProviders() + + result := make([]map[string]interface{}, 0, len(providers)) + for _, p := range providers { + hooks := make([]map[string]interface{}, 0) + for _, h := range p.extension.Manifest.GetPostProcessingHooks() { + hooks = append(hooks, map[string]interface{}{ + "id": h.ID, + "name": h.Name, + "description": h.Description, + "default_enabled": h.DefaultEnabled, + "supported_formats": h.SupportedFormats, + }) + } + + result = append(result, map[string]interface{}{ + "id": p.extension.ID, + "display_name": p.extension.Manifest.DisplayName, + "hooks": hooks, + }) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ==================== EXTENSION STORE ==================== + +// InitExtensionStoreJSON initializes the extension store with cache directory +func InitExtensionStoreJSON(cacheDir string) error { + InitExtensionStore(cacheDir) + return nil +} + +// GetStoreExtensionsJSON returns all extensions from the store with installation status +func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + // Force refresh if requested + if forceRefresh { + store.FetchRegistry(true) + } + + extensions, err := store.GetExtensionsWithStatus() + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(extensions) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SearchStoreExtensionsJSON searches extensions in the store +func SearchStoreExtensionsJSON(query, category string) (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + extensions, err := store.SearchExtensions(query, category) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(extensions) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetStoreCategoriesJSON returns all available categories +func GetStoreCategoriesJSON() (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + categories := store.GetCategories() + jsonBytes, err := json.Marshal(categories) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// DownloadStoreExtensionJSON downloads an extension from the store +// Returns the path to the downloaded file +func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID) + err := store.DownloadExtension(extensionID, destPath) + if err != nil { + return "", err + } + + return destPath, nil +} + +// ClearStoreCacheJSON clears the store cache +func ClearStoreCacheJSON() error { + store := GetExtensionStore() + if store == nil { + return fmt.Errorf("extension store not initialized") + } + + store.ClearCache() + return nil +} diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go new file mode 100644 index 00000000..df64b8dc --- /dev/null +++ b/go_backend/extension_manager.go @@ -0,0 +1,1012 @@ +// Package gobackend provides extension management functionality +package gobackend + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/dop251/goja" +) + +// 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) + } + + for i := 0; i < maxLen; i++ { + var n1, n2 int + if i < len(parts1) { + n1, _ = strconv.Atoi(parts1[i]) + } + if i < len(parts2) { + n2, _ = strconv.Atoi(parts2[i]) + } + + if n1 < n2 { + return -1 + } + if n1 > n2 { + return 1 + } + } + + return 0 +} + +// LoadedExtension represents an extension that has been loaded into memory +type LoadedExtension struct { + ID string `json:"id"` + Manifest *ExtensionManifest `json:"manifest"` + VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized) + 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) +} + +// ExtensionManager manages all loaded extensions +type ExtensionManager struct { + mu sync.RWMutex + extensions map[string]*LoadedExtension + extensionsDir string // Base directory for extensions + dataDir string // Base directory for extension data +} + +// Global extension manager instance +var ( + globalExtManager *ExtensionManager + globalExtManagerOnce sync.Once +) + +// GetExtensionManager returns the global extension manager instance +func GetExtensionManager() *ExtensionManager { + globalExtManagerOnce.Do(func() { + globalExtManager = &ExtensionManager{ + extensions: make(map[string]*LoadedExtension), + } + }) + return globalExtManager +} + +// SetDirectories sets the extensions and data directories +func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { + m.mu.Lock() + defer m.mu.Unlock() + + 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) + } + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + return nil +} + +// LoadExtensionFromFile loads an extension from a .spotiflac-ext file +func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) { + // Validate file extension + if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { + return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") + } + + // Open the zip file + zipReader, err := zip.OpenReader(filePath) + if err != nil { + return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package") + } + defer zipReader.Close() + + // Find and read manifest.json + var manifestData []byte + var hasIndexJS bool + for _, file := range zipReader.File { + name := filepath.Base(file.Name) + if name == "manifest.json" { + rc, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open manifest.json: %w", err) + } + manifestData, err = io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read manifest.json: %w", err) + } + } + if name == "index.js" { + hasIndexJS = true + } + } + + if manifestData == nil { + return nil, fmt.Errorf("Invalid extension package: manifest.json not found") + } + + if !hasIndexJS { + 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 + var existingDisplayName string + if exists { + existingVersion = existing.Manifest.Version + existingDisplayName = existing.Manifest.DisplayName + } + 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 + return m.UpgradeExtension(filePath) + } else if versionCompare == 0 { + return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion) + } else { + return nil, fmt.Errorf("Cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version) + } + } + + // 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 (preserving directory structure) + for _, file := range zipReader.File { + if file.FileInfo().IsDir() { + continue + } + + // Preserve relative path within the zip (support subdirectories) + // Clean the path to prevent path traversal attacks + relPath := filepath.Clean(file.Name) + if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { + GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) + continue + } + destPath := filepath.Join(extDir, relPath) + + // Create parent directories if needed + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err) + } + + // Create destination file + destFile, err := os.Create(destPath) + if err != nil { + return nil, fmt.Errorf("failed to create file %s: %w", destPath, err) + } + + // Copy content + srcFile, err := file.Open() + if err != nil { + destFile.Close() + return nil, fmt.Errorf("failed to open file in archive: %w", err) + } + + _, err = io.Copy(destFile, srcFile) + srcFile.Close() + destFile.Close() + if err != nil { + return nil, fmt.Errorf("failed to extract file: %w", err) + } + } + + // 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, + Enabled: false, // New extensions start disabled + DataDir: extDataDir, + SourceDir: extDir, + } + + // Initialize Goja VM + if err := m.initializeVM(ext); err != nil { + ext.Error = err.Error() + ext.Enabled = false + GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) + } + + m.extensions[manifest.Name] = ext + GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version) + + return ext, nil +} + +// 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)) + for i, arg := range call.Arguments { + args[i] = arg.Export() + } + GoLog("[Extension:%s] %v\n", ext.ID, args) + return goja.Undefined() + }) + 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() + }) + + // Run the extension code + _, err = vm.RunString(string(jsCode)) + if err != nil { + return fmt.Errorf("failed to execute extension code: %w", err) + } + + // Verify extension was registered + if registeredExtension == nil || goja.IsUndefined(registeredExtension) { + return fmt.Errorf("extension did not call registerExtension()") + } + + return nil +} + +// UnloadExtension unloads an extension by ID +func (m *ExtensionManager) UnloadExtension(extensionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return fmt.Errorf("Extension not found") + } + + // Call cleanup if VM is initialized + if ext.VM != nil { + // Try to call cleanup function + cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null") + if err != nil { + GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err) + } else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) { + GoLog("[Extension] Cleanup called for %s\n", extensionID) + } + } + + // Remove from registry + delete(m.extensions, extensionID) + GoLog("[Extension] Unloaded extension: %s\n", extensionID) + + return nil +} + +// GetExtension returns a loaded extension by ID +// Returns error if extension not found (gomobile compatible) +func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return nil, fmt.Errorf("Extension not found") + } + return ext, nil +} + +// GetAllExtensions returns all loaded extensions +func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]*LoadedExtension, 0, len(m.extensions)) + for _, ext := range m.extensions { + result = append(result, ext) + } + return result +} + +// SetExtensionEnabled enables or disables an extension +func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return fmt.Errorf("Extension not found") + } + + ext.Enabled = enabled + GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled]) + + // Persist enabled state to settings store + store := GetExtensionSettingsStore() + if err := store.Set(extensionID, "_enabled", enabled); err != nil { + GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err) + } + + return nil +} + +// LoadExtensionsFromDirectory scans a directory and loads all valid extensions +func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) { + var loaded []string + var errors []error + + entries, err := os.ReadDir(dirPath) + if err != nil { + if os.IsNotExist(err) { + return loaded, errors + } + return nil, []error{fmt.Errorf("failed to read extensions directory: %w", err)} + } + + 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())) + if err != nil { + GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err) + errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err)) + } else { + loaded = append(loaded, ext.ID) + } + } + } 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) + errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err)) + } else { + loaded = append(loaded, ext.ID) + } + } + } + + return loaded, errors +} + +// loadExtensionFromDirectory loads an extension from an already extracted directory +func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Read manifest + manifestPath := filepath.Join(dirPath, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read manifest.json: %w", err) + } + + // Parse and validate manifest + manifest, err := ParseManifest(manifestData) + if err != nil { + return nil, fmt.Errorf("Invalid extension manifest: %w", err) + } + + // Check if index.js exists + indexPath := filepath.Join(dirPath, "index.js") + if _, err := os.Stat(indexPath); os.IsNotExist(err) { + return nil, fmt.Errorf("Extension is missing index.js file") + } + + // Check if extension already loaded - skip silently (for directory loading on startup) + if existing, exists := m.extensions[manifest.Name]; exists { + GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName) + return existing, nil + } + + // Create data directory for extension + extDataDir := filepath.Join(m.dataDir, manifest.Name) + if err := os.MkdirAll(extDataDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create extension data directory: %w", err) + } + + // Create loaded extension + ext := &LoadedExtension{ + ID: manifest.Name, + Manifest: manifest, + Enabled: false, // Will be restored from settings store + DataDir: extDataDir, + SourceDir: dirPath, + } + + // Restore enabled state from settings store + store := GetExtensionSettingsStore() + if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil { + if enabled, ok := enabledVal.(bool); ok { + ext.Enabled = enabled + GoLog("[Extension] Restored enabled state for %s: %v\n", manifest.Name, enabled) + } + } + + // Initialize Goja VM + if err := m.initializeVM(ext); err != nil { + ext.Error = err.Error() + ext.Enabled = false + GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) + } + + m.extensions[manifest.Name] = ext + GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version) + + return ext, nil +} + +// RemoveExtension completely removes an extension (unload + delete files) +func (m *ExtensionManager) RemoveExtension(extensionID string) error { + ext, err := m.GetExtension(extensionID) + if err != nil { + return err + } + + // Unload first + if err := m.UnloadExtension(extensionID); err != nil { + return err + } + + // Remove source directory + if ext.SourceDir != "" { + if err := os.RemoveAll(ext.SourceDir); err != nil { + GoLog("[Extension] Warning: failed to remove source dir: %v\n", err) + } + } + + // Optionally remove data directory (keep for now to preserve settings) + // if ext.DataDir != "" { + // os.RemoveAll(ext.DataDir) + // } + + return nil +} + +// UpgradeExtension upgrades an existing extension from a new package file +// Only allows upgrades (new version > current version), not downgrades +func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { + // Validate file extension + if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { + return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") + } + + // Open the zip file + zipReader, err := zip.OpenReader(filePath) + if err != nil { + return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package") + } + defer zipReader.Close() + + // Find and read manifest.json + var manifestData []byte + var hasIndexJS bool + for _, file := range zipReader.File { + name := filepath.Base(file.Name) + if name == "manifest.json" { + rc, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open manifest.json: %w", err) + } + manifestData, err = io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read manifest.json: %w", err) + } + } + if name == "index.js" { + hasIndexJS = true + } + } + + if manifestData == nil { + return nil, fmt.Errorf("Invalid extension package: manifest.json not found") + } + + if !hasIndexJS { + 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() + + if !exists { + return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName) + } + + // Compare versions - only allow upgrade, not downgrade + versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version) + if versionCompare < 0 { + return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version) + } + if versionCompare == 0 { + return nil, fmt.Errorf("Extension is already at version %s", existing.Manifest.Version) + } + + GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version) + + // Save data directory path and enabled state (we want to preserve them) + extDataDir := existing.DataDir + extDir := existing.SourceDir + wasEnabled := existing.Enabled + + // Cleanup and unload existing extension + m.CleanupExtension(existing.ID) + m.UnloadExtension(existing.ID) + + // Remove old source files but keep data directory + if extDir != "" { + if err := os.RemoveAll(extDir); err != nil { + GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err) + } + } + + // Recreate extension directory + if err := os.MkdirAll(extDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create extension directory: %w", err) + } + + // Extract all files from new package (preserving directory structure) + for _, file := range zipReader.File { + if file.FileInfo().IsDir() { + continue + } + + // Preserve relative path within the zip (support subdirectories) + // Clean the path to prevent path traversal attacks + relPath := filepath.Clean(file.Name) + if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { + GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) + continue + } + destPath := filepath.Join(extDir, relPath) + + // Create parent directories if needed + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err) + } + + // Create destination file + destFile, err := os.Create(destPath) + if err != nil { + return nil, fmt.Errorf("failed to create file %s: %w", destPath, err) + } + + // Copy content + srcFile, err := file.Open() + if err != nil { + destFile.Close() + return nil, fmt.Errorf("failed to open file in archive: %w", err) + } + + _, err = io.Copy(destFile, srcFile) + srcFile.Close() + destFile.Close() + if err != nil { + return nil, fmt.Errorf("failed to extract file: %w", err) + } + } + + // Create new loaded extension (reusing data directory, preserving enabled state) + ext := &LoadedExtension{ + ID: newManifest.Name, + Manifest: newManifest, + Enabled: wasEnabled, // Preserve enabled state from before upgrade + DataDir: extDataDir, + SourceDir: extDir, + } + + // Initialize Goja VM + if err := m.initializeVM(ext); err != nil { + ext.Error = err.Error() + ext.Enabled = false + GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err) + } + + m.mu.Lock() + m.extensions[newManifest.Name] = ext + m.mu.Unlock() + + GoLog("[Extension] Upgraded extension: %s to v%s\n", newManifest.DisplayName, newManifest.Version) + + return ext, nil +} + +// ExtensionUpgradeInfo holds information about extension upgrade check +type ExtensionUpgradeInfo struct { + ExtensionID string `json:"extension_id"` + CurrentVersion string `json:"current_version"` + NewVersion string `json:"new_version"` + CanUpgrade bool `json:"can_upgrade"` + IsInstalled bool `json:"is_installed"` +} + +// checkExtensionUpgradeInternal checks if a package file is an upgrade for an existing extension +// Internal function that returns struct +func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { + // Validate file extension + if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { + return nil, fmt.Errorf("Invalid file format") + } + + // Open the zip file + zipReader, err := zip.OpenReader(filePath) + if err != nil { + return nil, fmt.Errorf("Cannot open extension file") + } + defer zipReader.Close() + + // Find and read manifest.json + var manifestData []byte + for _, file := range zipReader.File { + name := filepath.Base(file.Name) + if name == "manifest.json" { + rc, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open manifest.json") + } + manifestData, err = io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read manifest.json") + } + break + } + } + + if manifestData == nil { + 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() + + info := &ExtensionUpgradeInfo{ + ExtensionID: newManifest.Name, + NewVersion: newManifest.Version, + IsInstalled: exists, + } + + if !exists { + // Not installed - this is a new install, not upgrade + info.CurrentVersion = "" + info.CanUpgrade = false + } else { + // Compare versions + info.CurrentVersion = existing.Manifest.Version + info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0 + } + + return info, nil +} + +// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON +func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { + info, err := m.checkExtensionUpgradeInternal(filePath) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(info) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter +func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { + extensions := m.GetAllExtensions() + + type ExtensionInfo struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Homepage string `json:"homepage,omitempty"` + IconPath string `json:"icon_path,omitempty"` + Types []ExtensionType `json:"types"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + Error string `json:"error_message,omitempty"` + Settings []ExtensionSetting `json:"settings,omitempty"` + QualityOptions []QualityOption `json:"quality_options,omitempty"` + Permissions []string `json:"permissions"` + HasMetadataProvider bool `json:"has_metadata_provider"` + HasDownloadProvider bool `json:"has_download_provider"` + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` + SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` + TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` + PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` + } + + 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) + } + if ext.Manifest.Permissions.Storage { + permissions = append(permissions, "storage:enabled") + } + + // Determine status + status := "loaded" + if ext.Error != "" { + status = "error" + } else if !ext.Enabled { + status = "disabled" + } + + // Check for icon file + iconPath := "" + if ext.Manifest.Icon != "" && ext.SourceDir != "" { + possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon) + if _, err := os.Stat(possibleIcon); err == nil { + 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 { + iconPath = possibleIcon + } + } + + infos[i] = ExtensionInfo{ + ID: ext.ID, + Name: ext.Manifest.Name, + DisplayName: ext.Manifest.DisplayName, + Version: ext.Manifest.Version, + Author: ext.Manifest.Author, + Description: ext.Manifest.Description, + Homepage: ext.Manifest.Homepage, + IconPath: iconPath, + Types: ext.Manifest.Types, + Enabled: ext.Enabled, + Status: status, + Error: ext.Error, + Settings: ext.Manifest.Settings, + QualityOptions: ext.Manifest.QualityOptions, + Permissions: permissions, + HasMetadataProvider: ext.Manifest.IsMetadataProvider(), + HasDownloadProvider: ext.Manifest.IsDownloadProvider(), + SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment, + SearchBehavior: ext.Manifest.SearchBehavior, + TrackMatching: ext.Manifest.TrackMatching, + PostProcessing: ext.Manifest.PostProcessing, + } + } + + jsonBytes, err := json.Marshal(infos) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ==================== Extension Lifecycle ==================== + +// InitializeExtension calls the extension's initialize method with settings +func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return fmt.Errorf("Extension not found") + } + + if ext.VM == nil { + 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; + if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') { + try { + extension.initialize(settings); + return { success: true }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: true, message: 'no initialize function' }; + })() + `, string(settingsJSON)) + + result, err := ext.VM.RunString(script) + if err != nil { + ext.Error = fmt.Sprintf("initialize failed: %v", err) + ext.Enabled = false + GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err) + return err + } + + // Check result + if result != nil && !goja.IsUndefined(result) { + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + if success, ok := resultMap["success"].(bool); ok && !success { + errMsg := "unknown error" + if e, ok := resultMap["error"].(string); ok { + errMsg = e + } + ext.Error = errMsg + ext.Enabled = false + GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg) + return fmt.Errorf("initialize failed: %s", errMsg) + } + } + } + + GoLog("[Extension] Initialized %s\n", extensionID) + return nil +} + +// CleanupExtension calls the extension's cleanup method +func (m *ExtensionManager) CleanupExtension(extensionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return fmt.Errorf("Extension not found") + } + + if ext.VM == nil { + return nil // No VM, nothing to cleanup + } + + // Call cleanup function + script := ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { + try { + extension.cleanup(); + return { success: true }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: true, message: 'no cleanup function' }; + })() + ` + + result, err := ext.VM.RunString(script) + if err != nil { + GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err) + return err + } + + // Check result + if result != nil && !goja.IsUndefined(result) { + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + if success, ok := resultMap["success"].(bool); ok && !success { + errMsg := "unknown error" + if e, ok := resultMap["error"].(string); ok { + errMsg = e + } + GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg) + return fmt.Errorf("cleanup failed: %s", errMsg) + } + } + } + + GoLog("[Extension] Cleaned up %s\n", extensionID) + return nil +} + +// UnloadAllExtensions unloads all extensions gracefully +func (m *ExtensionManager) UnloadAllExtensions() { + m.mu.Lock() + extensionIDs := make([]string, 0, len(m.extensions)) + for id := range m.extensions { + extensionIDs = append(extensionIDs, id) + } + m.mu.Unlock() + + for _, id := range extensionIDs { + // Call cleanup first + m.CleanupExtension(id) + // Then unload + m.UnloadExtension(id) + } + + GoLog("[Extension] All extensions unloaded\n") +} diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go new file mode 100644 index 00000000..7a7a37f3 --- /dev/null +++ b/go_backend/extension_manifest.go @@ -0,0 +1,315 @@ +// Package gobackend provides extension manifest parsing and validation +package gobackend + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ExtensionType represents the type of extension +type ExtensionType string + +const ( + ExtensionTypeMetadataProvider ExtensionType = "metadata_provider" + ExtensionTypeDownloadProvider ExtensionType = "download_provider" +) + +// SettingType represents the type of a setting field +type SettingType string + +const ( + SettingTypeString SettingType = "string" + SettingTypeNumber SettingType = "number" + SettingTypeBool SettingType = "boolean" + SettingTypeSelect SettingType = "select" +) + +// ExtensionPermissions defines what resources an extension can access +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 +type ExtensionSetting struct { + Key string `json:"key"` + Type SettingType `json:"type"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` + Secret bool `json:"secret,omitempty"` + Default interface{} `json:"default,omitempty"` + Options []string `json:"options,omitempty"` // For select type +} + +// QualityOption represents a quality option for download providers +type QualityOption struct { + ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128") + Label string `json:"label"` // Display name (e.g., "MP3 320kbps") + Description string `json:"description"` // Optional description (e.g., "Best quality MP3") + Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings +} + +// QualitySpecificSetting represents a setting that's specific to a quality option +type QualitySpecificSetting struct { + Key string `json:"key"` + Type SettingType `json:"type"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` + Secret bool `json:"secret,omitempty"` + Default interface{} `json:"default,omitempty"` + Options []string `json:"options,omitempty"` // For select type +} + +// SearchBehaviorConfig defines custom search behavior for an extension +type SearchBehaviorConfig struct { + Enabled bool `json:"enabled"` // Whether extension provides custom search + Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box + Primary bool `json:"primary,omitempty"` // If true, show as primary search tab + Icon string `json:"icon,omitempty"` // Icon for search tab + ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3) + ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels + ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels +} + +// URLHandlerConfig defines custom URL handling for an extension +type URLHandlerConfig struct { + Enabled bool `json:"enabled"` // Whether extension handles URLs + Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com") +} + +// TrackMatchingConfig defines custom track matching behavior +type TrackMatchingConfig struct { + CustomMatching bool `json:"customMatching"` // Whether extension handles matching + Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom" + DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching +} + +// PostProcessingHook defines a post-processing hook +type PostProcessingHook struct { + ID string `json:"id"` // Unique identifier + Name string `json:"name"` // Display name + Description string `json:"description,omitempty"` // Description + DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default + SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"]) +} + +// PostProcessingConfig defines post-processing capabilities +type PostProcessingConfig struct { + Enabled bool `json:"enabled"` // Whether extension provides post-processing + Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks +} + +// ExtensionManifest represents the manifest.json of an extension +type ExtensionManifest struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Homepage string `json:"homepage,omitempty"` + Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") + Types []ExtensionType `json:"type"` + Permissions ExtensionPermissions `json:"permissions"` + Settings []ExtensionSetting `json:"settings,omitempty"` + QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers + MinAppVersion string `json:"minAppVersion,omitempty"` + SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify + SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) + SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior + URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling + TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching + PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks +} + +// ManifestValidationError represents a validation error in the manifest +type ManifestValidationError struct { + Field string + Message string +} + +func (e *ManifestValidationError) Error() string { + return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message) +} + +// ParseManifest parses and validates a manifest from JSON bytes +func ParseManifest(data []byte) (*ExtensionManifest, error) { + var manifest ExtensionManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse manifest JSON: %w", err) + } + + if err := manifest.Validate(); err != nil { + return nil, err + } + + return &manifest, nil +} + +// Validate checks if the manifest has all required fields and valid values +func (m *ExtensionManifest) Validate() error { + // Check required fields + if strings.TrimSpace(m.Name) == "" { + return &ManifestValidationError{Field: "name", Message: "name is required"} + } + + if strings.TrimSpace(m.Version) == "" { + return &ManifestValidationError{Field: "version", Message: "version is required"} + } + + if strings.TrimSpace(m.Author) == "" { + return &ManifestValidationError{Field: "author", Message: "author is required"} + } + + if strings.TrimSpace(m.Description) == "" { + return &ManifestValidationError{Field: "description", Message: "description is required"} + } + + if len(m.Types) == 0 { + return &ManifestValidationError{Field: "type", Message: "at least one type is required"} + } + + // Validate extension types + for _, t := range m.Types { + if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider { + return &ManifestValidationError{ + Field: "type", + Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t), + } + } + } + + // Validate settings if present + for i, setting := range m.Settings { + if strings.TrimSpace(setting.Key) == "" { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].key", i), + Message: "setting key is required", + } + } + + if setting.Type == "" { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].type", i), + Message: "setting type is required", + } + } + + // Validate setting type + validTypes := map[SettingType]bool{ + SettingTypeString: true, + SettingTypeNumber: true, + SettingTypeBool: true, + SettingTypeSelect: true, + } + if !validTypes[setting.Type] { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].type", i), + Message: fmt.Sprintf("invalid setting type: %s", setting.Type), + } + } + + // Select type requires options + if setting.Type == SettingTypeSelect && len(setting.Options) == 0 { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].options", i), + Message: "select type requires options", + } + } + } + + return nil +} + +// HasType checks if the extension has a specific type +func (m *ExtensionManifest) HasType(t ExtensionType) bool { + for _, et := range m.Types { + if et == t { + return true + } + } + return false +} + +// IsMetadataProvider returns true if extension provides metadata +func (m *ExtensionManifest) IsMetadataProvider() bool { + return m.HasType(ExtensionTypeMetadataProvider) +} + +// IsDownloadProvider returns true if extension provides downloads +func (m *ExtensionManifest) IsDownloadProvider() bool { + return m.HasType(ExtensionTypeDownloadProvider) +} + +// IsDomainAllowed checks if a domain is in the allowed network permissions +func (m *ExtensionManifest) IsDomainAllowed(domain string) bool { + domain = strings.ToLower(strings.TrimSpace(domain)) + for _, allowed := range m.Permissions.Network { + allowed = strings.ToLower(strings.TrimSpace(allowed)) + if allowed == domain { + return true + } + // Support wildcard subdomains (e.g., *.example.com) + if strings.HasPrefix(allowed, "*.") { + suffix := allowed[1:] // Remove the * + if strings.HasSuffix(domain, suffix) { + return true + } + } + } + return false +} + +// HasCustomSearch returns true if extension provides custom search +func (m *ExtensionManifest) HasCustomSearch() bool { + return m.SearchBehavior != nil && m.SearchBehavior.Enabled +} + +// HasCustomMatching returns true if extension provides custom track matching +func (m *ExtensionManifest) HasCustomMatching() bool { + return m.TrackMatching != nil && m.TrackMatching.CustomMatching +} + +// HasPostProcessing returns true if extension provides post-processing +func (m *ExtensionManifest) HasPostProcessing() bool { + return m.PostProcessing != nil && m.PostProcessing.Enabled +} + +// HasURLHandler returns true if extension handles custom URLs +func (m *ExtensionManifest) HasURLHandler() bool { + return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0 +} + +// MatchesURL checks if a URL matches any of the extension's URL patterns +func (m *ExtensionManifest) MatchesURL(urlStr string) bool { + if !m.HasURLHandler() { + return false + } + + // Parse URL to get host + urlStr = strings.ToLower(strings.TrimSpace(urlStr)) + for _, pattern := range m.URLHandler.Patterns { + pattern = strings.ToLower(strings.TrimSpace(pattern)) + // Check if URL contains the pattern (host match) + if strings.Contains(urlStr, pattern) { + return true + } + } + return false +} + +// GetPostProcessingHooks returns all post-processing hooks +func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook { + if m.PostProcessing == nil { + return nil + } + return m.PostProcessing.Hooks +} + +// ToJSON serializes the manifest to JSON +func (m *ExtensionManifest) ToJSON() ([]byte, error) { + return json.Marshal(m) +} diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go new file mode 100644 index 00000000..578bc2bb --- /dev/null +++ b/go_backend/extension_providers.go @@ -0,0 +1,1463 @@ +// Package gobackend provides extension provider interfaces +package gobackend + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/dop251/goja" +) + +// ==================== Metadata Types ==================== + +// ExtTrackMetadata represents track metadata from an extension +type ExtTrackMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + DurationMS int `json:"duration_ms"` + CoverURL string `json:"cover_url,omitempty"` + Images string `json:"images,omitempty"` // Alternative field for cover URL (used by some extensions) + ReleaseDate string `json:"release_date,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ISRC string `json:"isrc,omitempty"` + ProviderID string `json:"provider_id"` +} + +// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields +func (t *ExtTrackMetadata) ResolvedCoverURL() string { + if t.CoverURL != "" { + return t.CoverURL + } + return t.Images +} + +// ExtAlbumMetadata represents album metadata from an extension +type ExtAlbumMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + 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"` +} + +// ExtSearchResult represents search results from an extension +type ExtSearchResult struct { + Tracks []ExtTrackMetadata `json:"tracks"` + Total int `json:"total"` +} + +// ==================== Download Types ==================== + +// ExtAvailabilityResult represents availability check result +type ExtAvailabilityResult struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + TrackID string `json:"track_id,omitempty"` +} + +// ExtDownloadURLResult represents download URL info +type ExtDownloadURLResult struct { + URL string `json:"url"` + Format string `json:"format"` + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` +} + +// ExtDownloadResult represents download result from an extension +type ExtDownloadResult struct { + Success bool `json:"success"` + FilePath string `json:"file_path,omitempty"` + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ErrorType string `json:"error_type,omitempty"` + // Metadata returned by extension (optional - if provided, can skip enrichment) + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + ISRC string `json:"isrc,omitempty"` +} + +// ==================== Provider Wrapper ==================== + +// ExtensionProviderWrapper wraps an extension to call its provider methods +type ExtensionProviderWrapper struct { + extension *LoadedExtension + vm *goja.Runtime +} + +// NewExtensionProviderWrapper creates a new provider wrapper +func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper { + return &ExtensionProviderWrapper{ + extension: ext, + vm: ext.VM, + } +} + +// ==================== Metadata Provider Methods ==================== + +// SearchTracks searches for tracks using the extension +func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + // Call extension's searchTracks function + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') { + return extension.searchTracks(%q, %d); + } + return null; + })() + `, query, limit) + + 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) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("searchTracks returned null") + } + + // Convert result to Go struct + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var searchResult ExtSearchResult + + // Try to parse as ExtSearchResult object first + if err := json.Unmarshal(jsonBytes, &searchResult); err != nil { + // If that fails, try parsing as array of tracks directly + var tracks []ExtTrackMetadata + if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil { + return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr) + } + // Wrap array in ExtSearchResult + searchResult = ExtSearchResult{ + Tracks: tracks, + Total: len(tracks), + } + } + + // Set provider ID on all tracks + for i := range searchResult.Tracks { + searchResult.Tracks[i].ProviderID = p.extension.ID + } + + return &searchResult, nil +} + +// GetTrack gets track details by ID +func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') { + return extension.getTrack(%q); + } + return null; + })() + `, trackID) + + 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) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getTrack returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var track ExtTrackMetadata + if err := json.Unmarshal(jsonBytes, &track); err != nil { + return nil, fmt.Errorf("failed to parse track: %w", err) + } + + track.ProviderID = p.extension.ID + return &track, nil +} + +// GetAlbum gets album details by ID +func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { + return extension.getAlbum(%q); + } + return null; + })() + `, albumID) + + 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) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getAlbum returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var album ExtAlbumMetadata + if err := json.Unmarshal(jsonBytes, &album); err != nil { + return nil, fmt.Errorf("failed to parse album: %w", err) + } + + album.ProviderID = p.extension.ID + for i := range album.Tracks { + album.Tracks[i].ProviderID = p.extension.ID + } + return &album, nil +} + +// GetArtist gets artist details by ID +func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') { + return extension.getArtist(%q); + } + return null; + })() + `, artistID) + + 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) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getArtist returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var artist ExtArtistMetadata + if err := json.Unmarshal(jsonBytes, &artist); err != nil { + return nil, fmt.Errorf("failed to parse artist: %w", err) + } + + artist.ProviderID = p.extension.ID + 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 +func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) { + if !p.extension.Manifest.IsDownloadProvider() { + return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { + return extension.checkAvailability(%q, %q, %q); + } + return null; + })() + `, isrc, trackName, artistName) + + 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) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var availability ExtAvailabilityResult + if err := json.Unmarshal(jsonBytes, &availability); err != nil { + return nil, fmt.Errorf("failed to parse availability: %w", err) + } + + return &availability, nil +} + +// GetDownloadURL gets the download URL for a track +func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) { + if !p.extension.Manifest.IsDownloadProvider() { + return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') { + return extension.getDownloadUrl(%q, %q); + } + return null; + })() + `, trackID, quality) + + 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) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getDownloadUrl returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var urlResult ExtDownloadURLResult + if err := json.Unmarshal(jsonBytes, &urlResult); err != nil { + return nil, fmt.Errorf("failed to parse download URL: %w", err) + } + + return &urlResult, nil +} + +// ExtDownloadTimeout is longer for extension download operations (5 minutes) +const ExtDownloadTimeout = 5 * time.Minute + +// Download downloads a track with progress reporting +func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { + if !p.extension.Manifest.IsDownloadProvider() { + return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + // Set up progress callback in VM + p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + percent := int(call.Arguments[0].ToInteger()) + // Clamp to 0-100 + if percent < 0 { + percent = 0 + } + if percent > 100 { + percent = 100 + } + if onProgress != nil { + onProgress(percent) + } + } + return goja.Undefined() + }) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.download === 'function') { + return extension.download(%q, %q, %q, __onProgress); + } + return null; + })() + `, trackID, quality, outputPath) + + // 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: errMsg, + ErrorType: errType, + }, nil + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: "download returned null", + ErrorType: "not_implemented", + }, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: fmt.Sprintf("failed to marshal result: %v", err), + ErrorType: "internal_error", + }, nil + } + + var downloadResult ExtDownloadResult + if err := json.Unmarshal(jsonBytes, &downloadResult); err != nil { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: fmt.Sprintf("failed to parse result: %v", err), + ErrorType: "internal_error", + }, nil + } + + return &downloadResult, nil +} + +// ==================== Extension Manager Provider Methods ==================== + +// GetMetadataProviders returns all enabled metadata provider extensions +func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// GetDownloadProviders returns all enabled download provider extensions +func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// SearchTracksWithExtensions searches all metadata providers +func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) { + providers := m.GetMetadataProviders() + if len(providers) == 0 { + return nil, nil + } + + var allTracks []ExtTrackMetadata + for _, provider := range providers { + result, err := provider.SearchTracks(query, limit) + if err != nil { + GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err) + continue + } + if result != nil { + allTracks = append(allTracks, result.Tracks...) + } + } + + return allTracks, nil +} + +// ==================== Provider Priority ==================== + +// providerPriority stores the order of download providers +var providerPriority []string +var providerPriorityMu sync.RWMutex + +// metadataProviderPriority stores the order of metadata providers +var metadataProviderPriority []string +var metadataProviderPriorityMu sync.RWMutex + +// SetProviderPriority sets the order of download providers +// providerIDs should include both built-in ("tidal", "qobuz", "amazon") and extension IDs +func SetProviderPriority(providerIDs []string) { + providerPriorityMu.Lock() + defer providerPriorityMu.Unlock() + providerPriority = providerIDs + GoLog("[Extension] Download provider priority set: %v\n", providerIDs) +} + +// GetProviderPriority returns the current provider priority order +func GetProviderPriority() []string { + providerPriorityMu.RLock() + defer providerPriorityMu.RUnlock() + + if len(providerPriority) == 0 { + // Default order: built-in providers first + return []string{"tidal", "qobuz", "amazon"} + } + + result := make([]string, len(providerPriority)) + copy(result, providerPriority) + return result +} + +// SetMetadataProviderPriority sets the order of metadata providers +// providerIDs should include both built-in ("spotify", "deezer") and extension IDs +func SetMetadataProviderPriority(providerIDs []string) { + metadataProviderPriorityMu.Lock() + defer metadataProviderPriorityMu.Unlock() + metadataProviderPriority = providerIDs + GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs) +} + +// GetMetadataProviderPriority returns the current metadata provider priority order +func GetMetadataProviderPriority() []string { + metadataProviderPriorityMu.RLock() + defer metadataProviderPriorityMu.RUnlock() + + if len(metadataProviderPriority) == 0 { + // Default order: built-in providers first + return []string{"deezer", "spotify"} + } + + result := make([]string, len(metadataProviderPriority)) + copy(result, metadataProviderPriority) + return result +} + +// isBuiltInProvider checks if a provider ID is a built-in provider +func isBuiltInProvider(providerID string) bool { + switch providerID { + case "tidal", "qobuz", "amazon": + return true + default: + return false + } +} + +// ==================== Download with Fallback ==================== + +// DownloadWithExtensionFallback tries to download from providers in priority order +// Includes both built-in providers and extension providers +// If req.Source is set (extension ID), that extension is tried first +func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { + priority := GetProviderPriority() + extManager := GetExtensionManager() + + 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 { + // Update request with enriched data + if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC { + GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC) + req.ISRC = enrichedTrack.ISRC + } + // Can also update other fields if needed + 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) + + // For tracks from extension search, use the track ID directly (e.g., "youtube:VIDEO_ID") + // The extension already knows how to handle this ID + trackID := req.SpotifyID // This contains the extension's track ID (e.g., "youtube:xxx") + + 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 + result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { + if req.ItemID != "" { + SetItemProgress(req.ItemID, float64(percent), 0, 0) + } + }) + + if err == nil && result.Success { + resp := &DownloadResponse{ + Success: true, + Message: "Downloaded from " + req.Source, + FilePath: result.FilePath, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Service: req.Source, + } + + // If extension has skipMetadataEnrichment, copy metadata + if ext.Manifest.SkipMetadataEnrichment { + resp.SkipMetadataEnrichment = true + if result.Title != "" { + resp.Title = result.Title + } + if result.Artist != "" { + resp.Artist = result.Artist + } + if result.Album != "" { + resp.Album = result.Album + } + if result.AlbumArtist != "" { + resp.AlbumArtist = result.AlbumArtist + } + if result.TrackNumber > 0 { + resp.TrackNumber = result.TrackNumber + } + if result.DiscNumber > 0 { + resp.DiscNumber = result.DiscNumber + } + if result.ReleaseDate != "" { + resp.ReleaseDate = result.ReleaseDate + } + if result.CoverURL != "" { + resp.CoverURL = result.CoverURL + } + if result.ISRC != "" { + resp.ISRC = result.ISRC + } + } + + return resp, nil + } + + if err != nil { + lastErr = err + } else if result.ErrorMessage != "" { + lastErr = fmt.Errorf("%s", result.ErrorMessage) + } + GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr) + + // If skipBuiltInFallback is true, don't continue to other providers + if skipBuiltIn { + GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n") + return &DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Download failed: %v", lastErr), + ErrorType: "extension_error", + Service: req.Source, + }, nil + } + } else { + GoLog("[DownloadWithExtensionFallback] Source extension %s not available or not a download provider\n", req.Source) + } + } + + // Continue with priority list + for _, providerID := range priority { + // Skip if we already tried this as source + if providerID == req.Source { + continue + } + + // Skip built-in providers if skipBuiltIn is set + if skipBuiltIn && isBuiltInProvider(providerID) { + GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID) + continue + } + + GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) + + if isBuiltInProvider(providerID) { + // Use built-in provider + result, err := tryBuiltInProvider(providerID, req) + if err == nil && result.Success { + result.Service = providerID + return result, nil + } + if err != nil { + lastErr = err + GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err) + } + } else { + // Try extension provider + ext, err := extManager.GetExtension(providerID) + if err != nil || !ext.Enabled || ext.Error != "" { + GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID) + continue + } + + if !ext.Manifest.IsDownloadProvider() { + continue + } + + 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) + if err != nil { + lastErr = err + } + 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) + } + }) + + if err == nil && result.Success { + resp := &DownloadResponse{ + Success: true, + Message: "Downloaded from " + providerID, + FilePath: result.FilePath, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Service: providerID, + } + + // If extension has skipMetadataEnrichment and returned metadata, use it + if ext.Manifest.SkipMetadataEnrichment { + resp.SkipMetadataEnrichment = true + // Copy metadata from extension result if provided + if result.Title != "" { + resp.Title = result.Title + } + if result.Artist != "" { + resp.Artist = result.Artist + } + if result.Album != "" { + resp.Album = result.Album + } + if result.AlbumArtist != "" { + resp.AlbumArtist = result.AlbumArtist + } + if result.TrackNumber > 0 { + resp.TrackNumber = result.TrackNumber + } + if result.DiscNumber > 0 { + resp.DiscNumber = result.DiscNumber + } + if result.ReleaseDate != "" { + resp.ReleaseDate = result.ReleaseDate + } + if result.CoverURL != "" { + resp.CoverURL = result.CoverURL + } + if result.ISRC != "" { + resp.ISRC = result.ISRC + } + } + + return resp, nil + } + + if err != nil { + lastErr = err + } else if result.ErrorMessage != "" { + lastErr = fmt.Errorf("%s", result.ErrorMessage) + } + GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr) + } + } + + if lastErr != nil { + return &DownloadResponse{ + Success: false, + Error: fmt.Sprintf("All providers failed. Last error: %v", lastErr), + ErrorType: "not_found", + }, nil + } + + return &DownloadResponse{ + Success: false, + Error: "No providers available", + ErrorType: "not_found", + }, nil +} + +// tryBuiltInProvider attempts download from a built-in provider +func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) { + req.Service = providerID + + var result DownloadResult + var err error + + switch providerID { + case "tidal": + tidalResult, tidalErr := downloadFromTidal(req) + if tidalErr == nil { + result = DownloadResult{ + FilePath: tidalResult.FilePath, + BitDepth: tidalResult.BitDepth, + SampleRate: tidalResult.SampleRate, + Title: tidalResult.Title, + Artist: tidalResult.Artist, + Album: tidalResult.Album, + ReleaseDate: tidalResult.ReleaseDate, + TrackNumber: tidalResult.TrackNumber, + DiscNumber: tidalResult.DiscNumber, + ISRC: tidalResult.ISRC, + } + } + err = tidalErr + case "qobuz": + qobuzResult, qobuzErr := downloadFromQobuz(req) + if qobuzErr == nil { + result = DownloadResult{ + FilePath: qobuzResult.FilePath, + BitDepth: qobuzResult.BitDepth, + SampleRate: qobuzResult.SampleRate, + Title: qobuzResult.Title, + Artist: qobuzResult.Artist, + Album: qobuzResult.Album, + ReleaseDate: qobuzResult.ReleaseDate, + TrackNumber: qobuzResult.TrackNumber, + DiscNumber: qobuzResult.DiscNumber, + ISRC: qobuzResult.ISRC, + } + } + err = qobuzErr + case "amazon": + amazonResult, amazonErr := downloadFromAmazon(req) + if amazonErr == nil { + result = DownloadResult{ + FilePath: amazonResult.FilePath, + BitDepth: amazonResult.BitDepth, + SampleRate: amazonResult.SampleRate, + Title: amazonResult.Title, + Artist: amazonResult.Artist, + Album: amazonResult.Album, + ReleaseDate: amazonResult.ReleaseDate, + TrackNumber: amazonResult.TrackNumber, + DiscNumber: amazonResult.DiscNumber, + ISRC: amazonResult.ISRC, + } + } + err = amazonErr + default: + return nil, fmt.Errorf("unknown built-in provider: %s", providerID) + } + + if err != nil { + return nil, err + } + + return &DownloadResponse{ + Success: true, + Message: "Download complete", + FilePath: result.FilePath, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + ReleaseDate: result.ReleaseDate, + TrackNumber: result.TrackNumber, + DiscNumber: result.DiscNumber, + ISRC: result.ISRC, + }, nil +} + +// buildOutputPath builds the output file path from request +func buildOutputPath(req DownloadRequest) string { + metadata := map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "album_artist": req.AlbumArtist, + "track_number": req.TrackNumber, + "disc_number": req.DiscNumber, + "isrc": req.ISRC, + } + + filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) + if filename == "" { + filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)) + } + + return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename) +} + +// ==================== Custom Search ==================== + +// CustomSearch performs a custom search using an extension's search function +func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { + if !p.extension.Manifest.HasCustomSearch() { + return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + // Convert options to JSON + optionsJSON, _ := json.Marshal(options) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') { + return extension.customSearch(%q, %s); + } + return null; + })() + `, query, string(optionsJSON)) + + 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) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + // Return empty array instead of error for no results + return []ExtTrackMetadata{}, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var tracks []ExtTrackMetadata + if err := json.Unmarshal(jsonBytes, &tracks); err != nil { + return nil, fmt.Errorf("failed to parse search result: %w", err) + } + + // Return empty array if no tracks found + if tracks == nil { + tracks = []ExtTrackMetadata{} + } + + // Set provider ID on all tracks + for i := range tracks { + tracks[i].ProviderID = p.extension.ID + } + + return tracks, nil +} + +// ==================== Custom URL Handler ==================== + +// ExtURLHandleResult represents the result of URL handling +type ExtURLHandleResult struct { + Type string `json:"type"` // "track", "album", "playlist", "artist" + Track *ExtTrackMetadata `json:"track,omitempty"` // For single track + Tracks []ExtTrackMetadata `json:"tracks,omitempty"` // For album/playlist + Album *ExtAlbumMetadata `json:"album,omitempty"` // Album info + Artist *ExtArtistMetadata `json:"artist,omitempty"` // Artist info + Name string `json:"name,omitempty"` // Playlist/album name + CoverURL string `json:"cover_url,omitempty"` // Cover image +} + +// HandleURL processes a URL using the extension's URL handler +func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) { + if !p.extension.Manifest.HasURLHandler() { + return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') { + return extension.handleUrl(%q); + } + return null; + })() + `, url) + + 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) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("handleUrl returned null - URL not recognized") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var handleResult ExtURLHandleResult + if err := json.Unmarshal(jsonBytes, &handleResult); err != nil { + return nil, fmt.Errorf("failed to parse URL handle result: %w", err) + } + + // Set provider ID on tracks + if handleResult.Track != nil { + handleResult.Track.ProviderID = p.extension.ID + } + for i := range handleResult.Tracks { + handleResult.Tracks[i].ProviderID = p.extension.ID + } + + return &handleResult, nil +} + +// ==================== Custom Track Matching ==================== + +// MatchTrackResult represents the result of custom track matching +type MatchTrackResult struct { + Matched bool `json:"matched"` + TrackID string `json:"track_id,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// MatchTrack uses extension's custom matching algorithm +func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) { + if !p.extension.Manifest.HasCustomMatching() { + return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + sourceJSON, _ := json.Marshal(sourceTrack) + candidatesJSON, _ := json.Marshal(candidates) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.matchTrack === 'function') { + return extension.matchTrack(%s, %s); + } + return null; + })() + `, string(sourceJSON), string(candidatesJSON)) + + 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) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &MatchTrackResult{Matched: false, Reason: "not implemented"}, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var matchResult MatchTrackResult + if err := json.Unmarshal(jsonBytes, &matchResult); err != nil { + return nil, fmt.Errorf("failed to parse match result: %w", err) + } + + return &matchResult, nil +} + +// ==================== Post-Processing ==================== + +// PostProcessResult represents the result of post-processing +type PostProcessResult struct { + Success bool `json:"success"` + NewFilePath string `json:"new_file_path,omitempty"` + Error string `json:"error,omitempty"` + // Additional metadata that may have changed + BitDepth int `json:"bit_depth,omitempty"` + 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() { + return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + metadataJSON, _ := json.Marshal(metadata) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.postProcess === 'function') { + return extension.postProcess(%q, %s, %q); + } + return null; + })() + `, filePath, string(metadataJSON), hookID) + + result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) + if err != nil { + errMsg := err.Error() + if IsTimeoutError(err) { + errMsg = "postProcess timeout: extension took too long to complete" + } + return &PostProcessResult{ + Success: false, + Error: errMsg, + }, nil + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &PostProcessResult{ + Success: false, + Error: "postProcess returned null", + }, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return &PostProcessResult{ + Success: false, + Error: fmt.Sprintf("failed to marshal result: %v", err), + }, nil + } + + var postResult PostProcessResult + if err := json.Unmarshal(jsonBytes, &postResult); err != nil { + return &PostProcessResult{ + Success: false, + Error: fmt.Sprintf("failed to parse result: %v", err), + }, nil + } + + return &postResult, nil +} + +// ==================== Extension Manager Advanced Methods ==================== + +// GetSearchProviders returns all extensions that provide custom search +func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// GetURLHandlers returns all extensions that handle custom URLs +func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// FindURLHandler finds an extension that can handle the given URL +func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" { + return NewExtensionProviderWrapper(ext) + } + } + return nil +} + +// ExtURLHandleResultWithExtID wraps ExtURLHandleResult with extension ID for gomobile compatibility +type ExtURLHandleResultWithExtID struct { + Result *ExtURLHandleResult + ExtensionID string +} + +// HandleURLWithExtension tries to handle a URL with any matching extension +// Returns result with extension ID, or error if no handler found +func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) { + handler := m.FindURLHandler(url) + if handler == nil { + return nil, fmt.Errorf("no extension found to handle URL: %s", url) + } + + result, err := handler.HandleURL(url) + if err != nil { + return &ExtURLHandleResultWithExtID{ + Result: nil, + ExtensionID: handler.extension.ID, + }, err + } + + return &ExtURLHandleResultWithExtID{ + Result: result, + ExtensionID: handler.extension.ID, + }, nil +} + +// GetPostProcessingProviders returns all extensions that provide post-processing +func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// RunPostProcessing runs all enabled post-processing hooks on a file +func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) { + providers := m.GetPostProcessingProviders() + if len(providers) == 0 { + return &PostProcessResult{Success: true, NewFilePath: filePath}, nil + } + + currentPath := filePath + 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 + for _, format := range hook.SupportedFormats { + if "."+format == ext || format == ext[1:] { + supported = true + break + } + } + if !supported { + continue + } + } + + GoLog("[PostProcess] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentPath) + + result, err := provider.PostProcess(currentPath, metadata, hook.ID) + if err != nil { + GoLog("[PostProcess] Hook %s failed: %v\n", hook.ID, err) + continue + } + + if result.Success && result.NewFilePath != "" { + currentPath = result.NewFilePath + } + } + } + + return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil +} diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go new file mode 100644 index 00000000..81a8db45 --- /dev/null +++ b/go_backend/extension_runtime.go @@ -0,0 +1,340 @@ +// Package gobackend provides extension runtime with sandboxed execution +package gobackend + +import ( + "net/http" + "net/url" + "sync" + "time" + + "github.com/dop251/goja" +) + +// Default timeout for JS execution (30 seconds) +const DefaultJSTimeout = 30 * time.Second + +// Global auth state for extensions (stores pending auth codes) +var ( + extensionAuthState = make(map[string]*ExtensionAuthState) + extensionAuthStateMu sync.RWMutex +) + +// ExtensionAuthState holds auth state for an extension +type ExtensionAuthState struct { + PendingAuthURL string + AuthCode string + AccessToken string + RefreshToken string + ExpiresAt time.Time + IsAuthenticated bool + // PKCE support + PKCEVerifier string + PKCEChallenge string +} + +// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL +type PendingAuthRequest struct { + ExtensionID string + AuthURL string + CallbackURL string +} + +// Global pending auth requests (Flutter polls this) +var ( + pendingAuthRequests = make(map[string]*PendingAuthRequest) + pendingAuthRequestsMu sync.RWMutex +) + +// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter) +func GetPendingAuthRequest(extensionID string) *PendingAuthRequest { + pendingAuthRequestsMu.RLock() + defer pendingAuthRequestsMu.RUnlock() + return pendingAuthRequests[extensionID] +} + +// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL) +func ClearPendingAuthRequest(extensionID string) { + pendingAuthRequestsMu.Lock() + defer pendingAuthRequestsMu.Unlock() + delete(pendingAuthRequests, extensionID) +} + +// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback) +func SetExtensionAuthCode(extensionID string, authCode string) { + extensionAuthStateMu.Lock() + defer extensionAuthStateMu.Unlock() + + state, exists := extensionAuthState[extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[extensionID] = state + } + state.AuthCode = authCode +} + +// SetExtensionTokens sets access/refresh tokens for an extension +func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) { + extensionAuthStateMu.Lock() + defer extensionAuthStateMu.Unlock() + + state, exists := extensionAuthState[extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[extensionID] = state + } + state.AccessToken = accessToken + state.RefreshToken = refreshToken + state.ExpiresAt = expiresAt + state.IsAuthenticated = accessToken != "" +} + +// ExtensionRuntime provides sandboxed APIs for extensions +type ExtensionRuntime struct { + extensionID string + manifest *ExtensionManifest + settings map[string]interface{} + httpClient *http.Client + cookieJar http.CookieJar + dataDir string + vm *goja.Runtime +} + +// NewExtensionRuntime creates a new runtime for an extension +func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { + // Create a cookie jar for this extension + jar, _ := newSimpleCookieJar() + + runtime := &ExtensionRuntime{ + extensionID: ext.ID, + manifest: ext.Manifest, + settings: make(map[string]interface{}), + 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 +type simpleCookieJar struct { + cookies map[string][]*http.Cookie + mu sync.RWMutex +} + +func newSimpleCookieJar() (*simpleCookieJar, error) { + return &simpleCookieJar{ + cookies: make(map[string][]*http.Cookie), + }, nil +} + +func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.mu.Lock() + defer j.mu.Unlock() + key := u.Host + j.cookies[key] = append(j.cookies[key], cookies...) +} + +func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie { + j.mu.RLock() + defer j.mu.RUnlock() + return j.cookies[u.Host] +} + +// SetSettings updates the runtime settings +func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { + r.settings = settings +} + +// RegisterAPIs registers all sandboxed APIs to the Goja VM +func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { + r.vm = vm + + // HTTP client (sandboxed to allowed domains) + httpObj := vm.NewObject() + httpObj.Set("get", r.httpGet) + httpObj.Set("post", r.httpPost) + httpObj.Set("put", r.httpPut) + httpObj.Set("delete", r.httpDelete) + httpObj.Set("patch", r.httpPatch) + httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.) + httpObj.Set("clearCookies", r.httpClearCookies) + vm.Set("http", httpObj) + + // Storage API + storageObj := vm.NewObject() + storageObj.Set("get", r.storageGet) + storageObj.Set("set", r.storageSet) + storageObj.Set("remove", r.storageRemove) + vm.Set("storage", storageObj) + + // Secure Credentials API (encrypted storage for sensitive data) + credentialsObj := vm.NewObject() + credentialsObj.Set("store", r.credentialsStore) + credentialsObj.Set("get", r.credentialsGet) + credentialsObj.Set("remove", r.credentialsRemove) + credentialsObj.Set("has", r.credentialsHas) + vm.Set("credentials", credentialsObj) + + // Auth API (for OAuth and other auth flows) + authObj := vm.NewObject() + authObj.Set("openAuthUrl", r.authOpenUrl) + authObj.Set("getAuthCode", r.authGetCode) + authObj.Set("setAuthCode", r.authSetCode) + authObj.Set("clearAuth", r.authClear) + authObj.Set("isAuthenticated", r.authIsAuthenticated) + authObj.Set("getTokens", r.authGetTokens) + // PKCE support + authObj.Set("generatePKCE", r.authGeneratePKCE) + authObj.Set("getPKCE", r.authGetPKCE) + authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE) + authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE) + vm.Set("auth", authObj) + + // File operations (sandboxed) + fileObj := vm.NewObject() + fileObj.Set("download", r.fileDownload) + fileObj.Set("exists", r.fileExists) + fileObj.Set("delete", r.fileDelete) + fileObj.Set("read", r.fileRead) + fileObj.Set("write", r.fileWrite) + fileObj.Set("copy", r.fileCopy) + fileObj.Set("move", r.fileMove) + fileObj.Set("getSize", r.fileGetSize) + vm.Set("file", fileObj) + + // FFmpeg API (for post-processing) + ffmpegObj := vm.NewObject() + ffmpegObj.Set("execute", r.ffmpegExecute) + ffmpegObj.Set("getInfo", r.ffmpegGetInfo) + ffmpegObj.Set("convert", r.ffmpegConvert) + vm.Set("ffmpeg", ffmpegObj) + + // Track matching API + matchingObj := vm.NewObject() + matchingObj.Set("compareStrings", r.matchingCompareStrings) + matchingObj.Set("compareDuration", r.matchingCompareDuration) + matchingObj.Set("normalizeString", r.matchingNormalizeString) + vm.Set("matching", matchingObj) + + // Utilities + utilsObj := vm.NewObject() + utilsObj.Set("base64Encode", r.base64Encode) + utilsObj.Set("base64Decode", r.base64Decode) + utilsObj.Set("md5", r.md5Hash) + utilsObj.Set("sha256", r.sha256Hash) + utilsObj.Set("hmacSHA256", r.hmacSHA256) + utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64) + utilsObj.Set("hmacSHA1", r.hmacSHA1) + utilsObj.Set("parseJSON", r.parseJSON) + utilsObj.Set("stringifyJSON", r.stringifyJSON) + // Crypto utilities for developers + utilsObj.Set("encrypt", r.cryptoEncrypt) + utilsObj.Set("decrypt", r.cryptoDecrypt) + utilsObj.Set("generateKey", r.cryptoGenerateKey) + vm.Set("utils", utilsObj) + + // Log object (already set in extension_manager.go, but we can enhance it) + logObj := vm.NewObject() + logObj.Set("debug", r.logDebug) + logObj.Set("info", r.logInfo) + logObj.Set("warn", r.logWarn) + logObj.Set("error", r.logError) + vm.Set("log", logObj) + + // Go backend functions + gobackendObj := vm.NewObject() + gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) + vm.Set("gobackend", gobackendObj) + + // ==================== Browser-like Polyfills ==================== + // These make porting browser/Node.js libraries easier + + // Global fetch() - Promise-style HTTP API (browser-compatible) + vm.Set("fetch", r.fetchPolyfill) + + // Global atob/btoa - Base64 encoding (browser-compatible) + vm.Set("atob", r.atobPolyfill) + vm.Set("btoa", r.btoaPolyfill) + + // TextEncoder/TextDecoder constructors + r.registerTextEncoderDecoder(vm) + + // URL class for URL parsing + r.registerURLClass(vm) + + // JSON global (browser-compatible) + r.registerJSONGlobal(vm) +} diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go new file mode 100644 index 00000000..4e5102ef --- /dev/null +++ b/go_backend/extension_runtime_auth.go @@ -0,0 +1,547 @@ +// Package gobackend provides Auth API and PKCE support for extension runtime +package gobackend + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dop251/goja" +) + +// ==================== Auth API (OAuth Support) ==================== + +// authOpenUrl requests Flutter to open an OAuth URL +func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "auth URL is required", + }) + } + + authURL := call.Arguments[0].String() + callbackURL := "" + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) { + callbackURL = call.Arguments[1].String() + } + + // Store pending auth request for Flutter to pick up + pendingAuthRequestsMu.Lock() + pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ + ExtensionID: r.extensionID, + AuthURL: authURL, + CallbackURL: callbackURL, + } + pendingAuthRequestsMu.Unlock() + + // Update auth state + extensionAuthStateMu.Lock() + state, exists := extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + state.PendingAuthURL = authURL + state.AuthCode = "" // Clear any previous auth code + extensionAuthStateMu.Unlock() + + GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL) + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "message": "Auth URL will be opened by the app", + }) +} + +// authGetCode gets the auth code (set by Flutter after OAuth callback) +func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists || state.AuthCode == "" { + return goja.Undefined() + } + + return r.vm.ToValue(state.AuthCode) +} + +// authSetCode sets auth code and tokens (can be called by extension after token exchange) +func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + // Can accept either just auth code or an object with tokens + arg := call.Arguments[0].Export() + + extensionAuthStateMu.Lock() + defer extensionAuthStateMu.Unlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + + switch v := arg.(type) { + case string: + state.AuthCode = v + case map[string]interface{}: + if code, ok := v["code"].(string); ok { + state.AuthCode = code + } + if accessToken, ok := v["access_token"].(string); ok { + state.AccessToken = accessToken + state.IsAuthenticated = true + } + if refreshToken, ok := v["refresh_token"].(string); ok { + state.RefreshToken = refreshToken + } + if expiresIn, ok := v["expires_in"].(float64); ok { + state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) + } + } + + return r.vm.ToValue(true) +} + +// authClear clears all auth state for the extension +func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.Lock() + delete(extensionAuthState, r.extensionID) + extensionAuthStateMu.Unlock() + + pendingAuthRequestsMu.Lock() + delete(pendingAuthRequests, r.extensionID) + pendingAuthRequestsMu.Unlock() + + GoLog("[Extension:%s] Auth state cleared\n", r.extensionID) + return r.vm.ToValue(true) +} + +// authIsAuthenticated checks if extension has valid auth +func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists { + return r.vm.ToValue(false) + } + + // Check if token is expired + if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { + return r.vm.ToValue(false) + } + + return r.vm.ToValue(state.IsAuthenticated) +} + +// authGetTokens returns current tokens (for extension to use in API calls) +func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists { + return r.vm.ToValue(map[string]interface{}{}) + } + + result := map[string]interface{}{ + "access_token": state.AccessToken, + "refresh_token": state.RefreshToken, + "is_authenticated": state.IsAuthenticated, + } + + if !state.ExpiresAt.IsZero() { + result["expires_at"] = state.ExpiresAt.Unix() + result["is_expired"] = time.Now().After(state.ExpiresAt) + } + + return r.vm.ToValue(result) +} + +// ==================== PKCE Support ==================== + +// generatePKCEVerifier generates a cryptographically random code verifier +// Length should be between 43-128 characters (RFC 7636) +func generatePKCEVerifier(length int) (string, error) { + if length < 43 { + length = 43 + } + if length > 128 { + length = 128 + } + + // Generate random bytes + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + // Use base64url encoding without padding (RFC 7636 compliant) + verifier := base64.RawURLEncoding.EncodeToString(bytes) + + // Trim to exact length + if len(verifier) > length { + verifier = verifier[:length] + } + + return verifier, nil +} + +// generatePKCEChallenge generates a code challenge from verifier using S256 method +func generatePKCEChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + // Base64url encode without padding (RFC 7636) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// authGeneratePKCE generates a PKCE code verifier and challenge pair +// Returns: { verifier: string, challenge: string, method: "S256" } +func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { + // Default length is 64 characters + length := 64 + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 { + length = int(l) + } + } + + verifier, err := generatePKCEVerifier(length) + if err != nil { + GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + challenge := generatePKCEChallenge(verifier) + + // Store in auth state for later use + extensionAuthStateMu.Lock() + state, exists := extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + state.PKCEVerifier = verifier + state.PKCEChallenge = challenge + extensionAuthStateMu.Unlock() + + GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier)) + + return r.vm.ToValue(map[string]interface{}{ + "verifier": verifier, + "challenge": challenge, + "method": "S256", + }) +} + +// authGetPKCE returns the current PKCE verifier and challenge (if generated) +func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[r.extensionID] + if !exists || state.PKCEVerifier == "" { + return r.vm.ToValue(map[string]interface{}{}) + } + + return r.vm.ToValue(map[string]interface{}{ + "verifier": state.PKCEVerifier, + "challenge": state.PKCEChallenge, + "method": "S256", + }) +} + +// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL +// config: { authUrl, clientId, redirectUri, scope, extraParams } +// Returns: { success, authUrl, pkce: { verifier, challenge } } +func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "config object is required", + }) + } + + configObj := call.Arguments[0].Export() + config, ok := configObj.(map[string]interface{}) + if !ok { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "config must be an object", + }) + } + + // Required fields + authURL, _ := config["authUrl"].(string) + clientID, _ := config["clientId"].(string) + redirectURI, _ := config["redirectUri"].(string) + + if authURL == "" || clientID == "" || redirectURI == "" { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "authUrl, clientId, and redirectUri are required", + }) + } + + // Optional fields + scope, _ := config["scope"].(string) + extraParams, _ := config["extraParams"].(map[string]interface{}) + + // Generate PKCE + verifier, err := generatePKCEVerifier(64) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to generate PKCE: %v", err), + }) + } + challenge := generatePKCEChallenge(verifier) + + // Store PKCE in auth state + extensionAuthStateMu.Lock() + state, exists := extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + state.PKCEVerifier = verifier + state.PKCEChallenge = challenge + state.AuthCode = "" // Clear any previous auth code + extensionAuthStateMu.Unlock() + + // Build OAuth URL with PKCE parameters + parsedURL, err := url.Parse(authURL) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("invalid authUrl: %v", err), + }) + } + + query := parsedURL.Query() + query.Set("client_id", clientID) + query.Set("redirect_uri", redirectURI) + query.Set("response_type", "code") + query.Set("code_challenge", challenge) + query.Set("code_challenge_method", "S256") + + if scope != "" { + query.Set("scope", scope) + } + + // Add extra params + for k, v := range extraParams { + query.Set(k, fmt.Sprintf("%v", v)) + } + + parsedURL.RawQuery = query.Encode() + fullAuthURL := parsedURL.String() + + // Store pending auth request for Flutter + pendingAuthRequestsMu.Lock() + pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ + ExtensionID: r.extensionID, + AuthURL: fullAuthURL, + CallbackURL: redirectURI, + } + pendingAuthRequestsMu.Unlock() + + GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL) + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "authUrl": fullAuthURL, + "pkce": map[string]interface{}{ + "verifier": verifier, + "challenge": challenge, + "method": "S256", + }, + }) +} + +// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE +// config: { tokenUrl, clientId, redirectUri, code, extraParams } +// Uses the stored PKCE verifier automatically +func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "config object is required", + }) + } + + configObj := call.Arguments[0].Export() + config, ok := configObj.(map[string]interface{}) + if !ok { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "config must be an object", + }) + } + + // Required fields + tokenURL, _ := config["tokenUrl"].(string) + clientID, _ := config["clientId"].(string) + redirectURI, _ := config["redirectUri"].(string) + code, _ := config["code"].(string) + + if tokenURL == "" || clientID == "" || code == "" { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "tokenUrl, clientId, and code are required", + }) + } + + // Get stored PKCE verifier + extensionAuthStateMu.RLock() + state, exists := extensionAuthState[r.extensionID] + var verifier string + if exists { + verifier = state.PKCEVerifier + } + extensionAuthStateMu.RUnlock() + + if verifier == "" { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first", + }) + } + + // Validate domain + if err := r.validateDomain(tokenURL); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Build token request body + formData := url.Values{} + formData.Set("grant_type", "authorization_code") + formData.Set("client_id", clientID) + formData.Set("code", code) + formData.Set("code_verifier", verifier) + if redirectURI != "" { + formData.Set("redirect_uri", redirectURI) + } + + // Add extra params + if extraParams, ok := config["extraParams"].(map[string]interface{}); ok { + for k, v := range extraParams { + formData.Set(k, fmt.Sprintf("%v", v)) + } + } + + // Make token request + req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode())) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") + + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Parse response + var tokenResp map[string]interface{} + if err := json.Unmarshal(body, &tokenResp); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to parse token response: %v", err), + "body": string(body), + }) + } + + // Check for error in response + if errMsg, ok := tokenResp["error"].(string); ok { + errDesc, _ := tokenResp["error_description"].(string) + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": errMsg, + "error_description": errDesc, + }) + } + + // Extract tokens + accessToken, _ := tokenResp["access_token"].(string) + refreshToken, _ := tokenResp["refresh_token"].(string) + expiresIn, _ := tokenResp["expires_in"].(float64) + + if accessToken == "" { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "no access_token in response", + "body": string(body), + }) + } + + // Store tokens in auth state + extensionAuthStateMu.Lock() + state, exists = extensionAuthState[r.extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[r.extensionID] = state + } + state.AccessToken = accessToken + state.RefreshToken = refreshToken + state.IsAuthenticated = true + if expiresIn > 0 { + state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) + } + // Clear PKCE after successful exchange + state.PKCEVerifier = "" + state.PKCEChallenge = "" + extensionAuthStateMu.Unlock() + + GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID) + + // Return full token response + result := map[string]interface{}{ + "success": true, + "access_token": accessToken, + "refresh_token": refreshToken, + "token_type": tokenResp["token_type"], + } + if expiresIn > 0 { + result["expires_in"] = expiresIn + } + // Include any additional fields from response + if scope, ok := tokenResp["scope"].(string); ok { + result["scope"] = scope + } + + return r.vm.ToValue(result) +} diff --git a/go_backend/extension_runtime_ffmpeg.go b/go_backend/extension_runtime_ffmpeg.go new file mode 100644 index 00000000..889456bb --- /dev/null +++ b/go_backend/extension_runtime_ffmpeg.go @@ -0,0 +1,204 @@ +// Package gobackend provides FFmpeg API for extension runtime +package gobackend + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/dop251/goja" +) + +// ==================== FFmpeg API (Post-Processing) ==================== + +// FFmpegCommand holds a pending FFmpeg command for Flutter to execute +type FFmpegCommand struct { + ExtensionID string + Command string + InputPath string + OutputPath string + Completed bool + Success bool + Error string + Output string +} + +// Global FFmpeg command queue +var ( + ffmpegCommands = make(map[string]*FFmpegCommand) + ffmpegCommandsMu sync.RWMutex + ffmpegCommandID int64 +) + +// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter) +func GetPendingFFmpegCommand(commandID string) *FFmpegCommand { + ffmpegCommandsMu.RLock() + defer ffmpegCommandsMu.RUnlock() + return ffmpegCommands[commandID] +} + +// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter) +func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) { + ffmpegCommandsMu.Lock() + defer ffmpegCommandsMu.Unlock() + if cmd, exists := ffmpegCommands[commandID]; exists { + cmd.Completed = true + cmd.Success = success + cmd.Output = output + cmd.Error = errorMsg + } +} + +// ClearFFmpegCommand removes a completed FFmpeg command +func ClearFFmpegCommand(commandID string) { + ffmpegCommandsMu.Lock() + defer ffmpegCommandsMu.Unlock() + delete(ffmpegCommands, commandID) +} + +// ffmpegExecute queues an FFmpeg command for execution by Flutter +func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "command is required", + }) + } + + command := call.Arguments[0].String() + + // Generate unique command ID + ffmpegCommandsMu.Lock() + ffmpegCommandID++ + cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID) + ffmpegCommands[cmdID] = &FFmpegCommand{ + ExtensionID: r.extensionID, + Command: command, + Completed: false, + } + ffmpegCommandsMu.Unlock() + + GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID) + + // Wait for completion (with timeout) + timeout := 5 * time.Minute + start := time.Now() + for { + ffmpegCommandsMu.RLock() + cmd := ffmpegCommands[cmdID] + completed := cmd != nil && cmd.Completed + ffmpegCommandsMu.RUnlock() + + if completed { + ffmpegCommandsMu.RLock() + result := map[string]interface{}{ + "success": cmd.Success, + "output": cmd.Output, + } + if cmd.Error != "" { + result["error"] = cmd.Error + } + ffmpegCommandsMu.RUnlock() + + // Cleanup + ClearFFmpegCommand(cmdID) + return r.vm.ToValue(result) + } + + if time.Since(start) > timeout { + ClearFFmpegCommand(cmdID) + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "FFmpeg command timed out", + }) + } + + time.Sleep(100 * time.Millisecond) + } +} + +// ffmpegGetInfo gets audio file information using FFprobe +func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "file path is required", + }) + } + + filePath := call.Arguments[0].String() + + // Use Go's built-in audio quality function + quality, err := GetAudioQuality(filePath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "bit_depth": quality.BitDepth, + "sample_rate": quality.SampleRate, + "total_samples": quality.TotalSamples, + "duration": float64(quality.TotalSamples) / float64(quality.SampleRate), + }) +} + +// ffmpegConvert is a helper for common conversion operations +func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "input and output paths are required", + }) + } + + inputPath := call.Arguments[0].String() + outputPath := call.Arguments[1].String() + + // Get options if provided + options := map[string]interface{}{} + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok { + options = opts + } + } + + // Build FFmpeg command + var cmdParts []string + cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath)) + + // Audio codec + if codec, ok := options["codec"].(string); ok { + cmdParts = append(cmdParts, "-c:a", codec) + } + + // Bitrate + if bitrate, ok := options["bitrate"].(string); ok { + cmdParts = append(cmdParts, "-b:a", bitrate) + } + + // Sample rate + if sampleRate, ok := options["sample_rate"].(float64); ok { + cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate))) + } + + // Channels + if channels, ok := options["channels"].(float64); ok { + cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels))) + } + + // Overwrite output + cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath)) + + command := strings.Join(cmdParts, " ") + + // Execute via ffmpegExecute + execCall := goja.FunctionCall{ + Arguments: []goja.Value{r.vm.ToValue(command)}, + } + return r.ffmpegExecute(execCall) +} diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go new file mode 100644 index 00000000..82ccec3b --- /dev/null +++ b/go_backend/extension_runtime_file.go @@ -0,0 +1,523 @@ +// Package gobackend provides File API for extension runtime +package gobackend + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/dop251/goja" +) + +// ==================== File API (Sandboxed) ==================== + +// 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) + + // SECURITY: Block absolute paths by default + // Only allow if path is in explicitly allowed download directories + if filepath.IsAbs(cleanPath) { + 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 (extension's sandbox) + fullPath := filepath.Join(r.dataDir, cleanPath) + + // Resolve to absolute path + absPath, err := filepath.Abs(fullPath) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + + // 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) + } + + return absPath, nil +} + +// fileDownload downloads a file from URL to the specified path +// Supports progress callback via options.onProgress +func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "URL and output path are required", + }) + } + + urlStr := call.Arguments[0].String() + outputPath := call.Arguments[1].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Validate output path (allows absolute paths for download queue) + fullPath, err := r.validatePath(outputPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Get options if provided + var onProgress goja.Callable + var headers map[string]string + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + optionsObj := call.Arguments[2].Export() + if opts, ok := optionsObj.(map[string]interface{}); ok { + // Extract headers + if h, ok := opts["headers"].(map[string]interface{}); ok { + headers = make(map[string]string) + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + // Extract onProgress callback + if progressVal, ok := opts["onProgress"]; ok { + if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok { + onProgress = callable + } + } + } + } + + // Create directory if needed + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create directory: %v", err), + }) + } + + // Create HTTP request + req, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Set headers + for k, v := range headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") + } + + // Download file + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("HTTP error: %d", resp.StatusCode), + }) + } + + // Create output file + out, err := os.Create(fullPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create file: %v", err), + }) + } + defer out.Close() + + // Get content length for progress + contentLength := resp.ContentLength + + // Copy content with progress reporting + var written int64 + buf := make([]byte, 32*1024) // 32KB buffer + for { + nr, er := resp.Body.Read(buf) + if nr > 0 { + nw, ew := out.Write(buf[0:nr]) + if nw < 0 || nr < nw { + nw = 0 + if ew == nil { + ew = fmt.Errorf("invalid write result") + } + } + written += int64(nw) + if ew != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to write file: %v", ew), + }) + } + if nr != nw { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "short write", + }) + } + + // Report progress + if onProgress != nil && contentLength > 0 { + _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) + } + } + if er != nil { + if er != io.EOF { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to read response: %v", er), + }) + } + break + } + } + + GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath) + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullPath, + "size": written, + }) +} + +// fileExists checks if a file exists in the sandbox +func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(false) + } + + _, err = os.Stat(fullPath) + return r.vm.ToValue(err == nil) +} + +// fileDelete deletes a file in the sandbox +func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path is required", + }) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + if err := os.Remove(fullPath); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + }) +} + +// fileRead reads a file from the sandbox +func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path is required", + }) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + data, err := os.ReadFile(fullPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "data": string(data), + }) +} + +// fileWrite writes data to a file in the sandbox +func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path and data are required", + }) + } + + path := call.Arguments[0].String() + data := call.Arguments[1].String() + + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Create directory if needed + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create directory: %v", err), + }) + } + + if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullPath, + }) +} + +// fileCopy copies a file within the sandbox +func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "source and destination paths are required", + }) + } + + srcPath := call.Arguments[0].String() + dstPath := call.Arguments[1].String() + + fullSrc, err := r.validatePath(srcPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + fullDst, err := r.validatePath(dstPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Read source file + data, err := os.ReadFile(fullSrc) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to read source: %v", err), + }) + } + + // Create destination directory if needed + dir := filepath.Dir(fullDst) + if err := os.MkdirAll(dir, 0755); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create directory: %v", err), + }) + } + + // Write to destination + if err := os.WriteFile(fullDst, data, 0644); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to write destination: %v", err), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullDst, + }) +} + +// fileMove moves/renames a file within the sandbox +func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "source and destination paths are required", + }) + } + + srcPath := call.Arguments[0].String() + dstPath := call.Arguments[1].String() + + fullSrc, err := r.validatePath(srcPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + fullDst, err := r.validatePath(dstPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + // Create destination directory if needed + dir := filepath.Dir(fullDst) + if err := os.MkdirAll(dir, 0755); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to create directory: %v", err), + }) + } + + if err := os.Rename(fullSrc, fullDst); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("failed to move file: %v", err), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "path": fullDst, + }) +} + +// fileGetSize returns the size of a file in bytes +func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "path is required", + }) + } + + path := call.Arguments[0].String() + fullPath, err := r.validatePath(path) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + info, err := os.Stat(fullPath) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "size": info.Size(), + }) +} diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go new file mode 100644 index 00000000..61c7b36c --- /dev/null +++ b/go_backend/extension_runtime_http.go @@ -0,0 +1,505 @@ +// Package gobackend provides HTTP API for extension runtime +package gobackend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/dop251/goja" +) + +// ==================== HTTP API (Sandboxed) ==================== + +// HTTPResponse represents the response from an HTTP request +type HTTPResponse struct { + StatusCode int `json:"statusCode"` + Body string `json:"body"` + Headers map[string]string `json:"headers"` +} + +// validateDomain checks if the domain is allowed by the extension's permissions +func (r *ExtensionRuntime) validateDomain(urlStr string) error { + parsed, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + 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) + } + + return nil +} + +// httpGet performs a GET request (sandboxed) +func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Get headers if provided + headers := make(map[string]string) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + headersObj := call.Arguments[1].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + + // Create request + req, err := http.NewRequest("GET", urlStr, nil) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Only set default User-Agent if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers - return all values as arrays for multi-value headers (cookies, etc.) + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpPost performs a POST request (sandboxed) +func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Get body if provided - support both string and object + var bodyStr string + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + bodyArg := call.Arguments[1].Export() + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + // Auto-stringify objects and arrays to JSON + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + // Fallback to string conversion + bodyStr = call.Arguments[1].String() + } + } + + // Get headers if provided + headers := make(map[string]string) + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + headersObj := call.Arguments[2].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + + // Create request + req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr)) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Only set defaults if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers - return all values as arrays for multi-value headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.) +// Usage: http.request(url, options) where options = { method, body, headers } +func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Default options + method := "GET" + var bodyStr string + headers := make(map[string]string) + + // Parse options if provided + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + optionsObj := call.Arguments[1].Export() + if opts, ok := optionsObj.(map[string]interface{}); ok { + // Get method + if m, ok := opts["method"].(string); ok { + method = strings.ToUpper(m) + } + + // Get body - support both string and object + if bodyArg, ok := opts["body"]; ok && bodyArg != nil { + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + // Auto-stringify objects and arrays to JSON + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + bodyStr = fmt.Sprintf("%v", v) + } + } + + // Get headers + if h, ok := opts["headers"].(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + + // Create request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Only set defaults if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers - return all values as arrays for multi-value headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values + } + } + + // Return response with helper properties + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpPut performs a PUT request (shortcut for http.request with method: "PUT") +func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("PUT", call) +} + +// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE") +func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("DELETE", call) +} + +// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH") +func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("PATCH", call) +} + +// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts +// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers) +func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + var bodyStr string + headers := make(map[string]string) + + // For DELETE, second arg is headers; for PUT/PATCH, second arg is body + if method == "DELETE" { + // http.delete(url, headers) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + headersObj := call.Arguments[1].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } else { + // http.put(url, body, headers) / http.patch(url, body, headers) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + bodyArg := call.Arguments[1].Export() + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + bodyStr = call.Arguments[1].String() + } + } + + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + headersObj := call.Arguments[2].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + + // Create request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpClearCookies clears all cookies for this extension +func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { + if jar, ok := r.cookieJar.(*simpleCookieJar); ok { + jar.mu.Lock() + jar.cookies = make(map[string][]*http.Cookie) + jar.mu.Unlock() + GoLog("[Extension:%s] Cookies cleared\n", r.extensionID) + return r.vm.ToValue(true) + } + return r.vm.ToValue(false) +} diff --git a/go_backend/extension_runtime_matching.go b/go_backend/extension_runtime_matching.go new file mode 100644 index 00000000..9e56fa80 --- /dev/null +++ b/go_backend/extension_runtime_matching.go @@ -0,0 +1,151 @@ +// Package gobackend provides Track Matching API for extension runtime +package gobackend + +import ( + "strings" + + "github.com/dop251/goja" +) + +// ==================== Track Matching API ==================== + +// matchingCompareStrings compares two strings with fuzzy matching +func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(0.0) + } + + str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String())) + str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String())) + + if str1 == str2 { + return r.vm.ToValue(1.0) + } + + // Calculate Levenshtein distance-based similarity + similarity := calculateStringSimilarity(str1, str2) + return r.vm.ToValue(similarity) +} + +// matchingCompareDuration compares two durations with tolerance +func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(false) + } + + dur1 := int(call.Arguments[0].ToInteger()) + dur2 := int(call.Arguments[1].ToInteger()) + + // Default tolerance: 3 seconds + tolerance := 3000 // milliseconds + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) { + tolerance = int(call.Arguments[2].ToInteger()) + } + + diff := dur1 - dur2 + if diff < 0 { + diff = -diff + } + + return r.vm.ToValue(diff <= tolerance) +} + +// matchingNormalizeString normalizes a string for comparison +func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + + str := call.Arguments[0].String() + normalized := normalizeStringForMatching(str) + return r.vm.ToValue(normalized) +} + +// calculateStringSimilarity calculates similarity between two strings (0-1) +func calculateStringSimilarity(s1, s2 string) float64 { + if len(s1) == 0 && len(s2) == 0 { + return 1.0 + } + if len(s1) == 0 || len(s2) == 0 { + return 0.0 + } + + // Use Levenshtein distance + distance := levenshteinDistance(s1, s2) + maxLen := len(s1) + if len(s2) > maxLen { + maxLen = len(s2) + } + + return 1.0 - float64(distance)/float64(maxLen) +} + +// levenshteinDistance calculates the Levenshtein distance between two strings +func levenshteinDistance(s1, s2 string) int { + if len(s1) == 0 { + return len(s2) + } + if len(s2) == 0 { + return len(s1) + } + + // Create matrix + matrix := make([][]int, len(s1)+1) + for i := range matrix { + matrix[i] = make([]int, len(s2)+1) + matrix[i][0] = i + } + for j := range matrix[0] { + matrix[0][j] = j + } + + // Fill matrix + for i := 1; i <= len(s1); i++ { + for j := 1; j <= len(s2); j++ { + cost := 1 + if s1[i-1] == s2[j-1] { + cost = 0 + } + matrix[i][j] = min( + matrix[i-1][j]+1, // deletion + matrix[i][j-1]+1, // insertion + matrix[i-1][j-1]+cost, // substitution + ) + } + } + + return matrix[len(s1)][len(s2)] +} + +// normalizeStringForMatching normalizes a string for comparison +func normalizeStringForMatching(s string) string { + // Convert to lowercase + s = strings.ToLower(s) + + // Remove common suffixes/prefixes + suffixes := []string{ + " (remastered)", " (remaster)", " - remastered", " - remaster", + " (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition", + " (explicit)", " (clean)", " [explicit]", " [clean]", + " (album version)", " (single version)", " (radio edit)", + " (feat.", " (ft.", " feat.", " ft.", + } + for _, suffix := range suffixes { + if idx := strings.Index(s, suffix); idx != -1 { + s = s[:idx] + } + } + + // Remove special characters + var result strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' { + result.WriteRune(r) + } + } + + // Collapse multiple spaces + s = strings.Join(strings.Fields(result.String()), " ") + + return strings.TrimSpace(s) +} diff --git a/go_backend/extension_runtime_polyfills.go b/go_backend/extension_runtime_polyfills.go new file mode 100644 index 00000000..5293841e --- /dev/null +++ b/go_backend/extension_runtime_polyfills.go @@ -0,0 +1,488 @@ +// Package gobackend provides Browser-like Polyfills for extension runtime +package gobackend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/dop251/goja" +) + +// ==================== Browser-like Polyfills ==================== +// These polyfills make porting browser/Node.js libraries easier +// without compromising sandbox security + +// fetchPolyfill implements browser-compatible fetch() API +// Returns a Promise-like object with json(), text() methods +func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.createFetchError("URL is required") + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err) + return r.createFetchError(err.Error()) + } + + // Parse options + method := "GET" + var bodyStr string + headers := make(map[string]string) + + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + optionsObj := call.Arguments[1].Export() + if opts, ok := optionsObj.(map[string]interface{}); ok { + // Method + if m, ok := opts["method"].(string); ok { + method = strings.ToUpper(m) + } + + // Body - support string, object (auto-stringify), or nil + if bodyArg, ok := opts["body"]; ok && bodyArg != nil { + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err)) + } + bodyStr = string(jsonBytes) + default: + bodyStr = fmt.Sprintf("%v", v) + } + } + + // Headers + if h, ok := opts["headers"]; ok && h != nil { + switch hv := h.(type) { + case map[string]interface{}: + for k, v := range hv { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + } + + // Create HTTP request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.createFetchError(err.Error()) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Set defaults if not provided + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.createFetchError(err.Error()) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.createFetchError(err.Error()) + } + + // Extract response headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v + } + } + + // Create Response object (browser-compatible) + responseObj := r.vm.NewObject() + responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) + responseObj.Set("status", resp.StatusCode) + responseObj.Set("statusText", http.StatusText(resp.StatusCode)) + responseObj.Set("headers", respHeaders) + responseObj.Set("url", urlStr) + + // Store body for methods + bodyString := string(body) + + // text() method - returns body as string + responseObj.Set("text", func(call goja.FunctionCall) goja.Value { + return r.vm.ToValue(bodyString) + }) + + // json() method - parses body as JSON + responseObj.Set("json", func(call goja.FunctionCall) goja.Value { + var result interface{} + if err := json.Unmarshal(body, &result); err != nil { + GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err) + return goja.Undefined() + } + return r.vm.ToValue(result) + }) + + // arrayBuffer() method - returns body as array (simplified) + responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value { + // Return as array of bytes + byteArray := make([]interface{}, len(body)) + for i, b := range body { + byteArray[i] = int(b) + } + return r.vm.ToValue(byteArray) + }) + + return responseObj +} + +// createFetchError creates a fetch error response +func (r *ExtensionRuntime) createFetchError(message string) goja.Value { + errorObj := r.vm.NewObject() + errorObj.Set("ok", false) + errorObj.Set("status", 0) + errorObj.Set("statusText", "Network Error") + errorObj.Set("error", message) + errorObj.Set("text", func(call goja.FunctionCall) goja.Value { + return r.vm.ToValue("") + }) + errorObj.Set("json", func(call goja.FunctionCall) goja.Value { + return goja.Undefined() + }) + return errorObj +} + +// atobPolyfill implements browser atob() - decode base64 to string +func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + decoded, err := base64.StdEncoding.DecodeString(input) + if err != nil { + // Try URL-safe base64 + decoded, err = base64.URLEncoding.DecodeString(input) + if err != nil { + GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err) + return r.vm.ToValue("") + } + } + return r.vm.ToValue(string(decoded)) +} + +// btoaPolyfill implements browser btoa() - encode string to base64 +func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) +} + +// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes +func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { + // TextEncoder constructor + vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { + encoder := call.This + encoder.Set("encoding", "utf-8") + + // encode() method - string to Uint8Array + encoder.Set("encode", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue([]byte{}) + } + input := call.Arguments[0].String() + bytes := []byte(input) + + // Return as array (Uint8Array-like) + result := make([]interface{}, len(bytes)) + for i, b := range bytes { + result[i] = int(b) + } + return vm.ToValue(result) + }) + + // encodeInto() method + encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value { + // Simplified implementation + if len(call.Arguments) < 2 { + return vm.ToValue(map[string]interface{}{"read": 0, "written": 0}) + } + input := call.Arguments[0].String() + return vm.ToValue(map[string]interface{}{ + "read": len(input), + "written": len([]byte(input)), + }) + }) + + return nil + }) + + // TextDecoder constructor + vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object { + decoder := call.This + + // Get encoding from arguments (default: utf-8) + encoding := "utf-8" + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + encoding = call.Arguments[0].String() + } + decoder.Set("encoding", encoding) + decoder.Set("fatal", false) + decoder.Set("ignoreBOM", false) + + // decode() method - Uint8Array to string + decoder.Set("decode", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue("") + } + + // Handle different input types + input := call.Arguments[0].Export() + var bytes []byte + + switch v := input.(type) { + case []byte: + bytes = v + case []interface{}: + bytes = make([]byte, len(v)) + for i, val := range v { + switch n := val.(type) { + case int64: + bytes[i] = byte(n) + case float64: + bytes[i] = byte(n) + case int: + bytes[i] = byte(n) + } + } + case string: + // Already a string, just return it + return vm.ToValue(v) + default: + return vm.ToValue("") + } + + return vm.ToValue(string(bytes)) + }) + + return nil + }) +} + +// registerURLClass registers the URL class for URL parsing +func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) { + vm.Set("URL", func(call goja.ConstructorCall) *goja.Object { + urlObj := call.This + + if len(call.Arguments) < 1 { + urlObj.Set("href", "") + return nil + } + + urlStr := call.Arguments[0].String() + + // Handle relative URLs with base + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) { + baseStr := call.Arguments[1].String() + baseURL, err := url.Parse(baseStr) + if err == nil { + relURL, err := url.Parse(urlStr) + if err == nil { + urlStr = baseURL.ResolveReference(relURL).String() + } + } + } + + parsed, err := url.Parse(urlStr) + if err != nil { + urlObj.Set("href", urlStr) + return nil + } + + // Set URL properties + urlObj.Set("href", parsed.String()) + urlObj.Set("protocol", parsed.Scheme+":") + urlObj.Set("host", parsed.Host) + urlObj.Set("hostname", parsed.Hostname()) + urlObj.Set("port", parsed.Port()) + urlObj.Set("pathname", parsed.Path) + urlObj.Set("search", "") + if parsed.RawQuery != "" { + urlObj.Set("search", "?"+parsed.RawQuery) + } + urlObj.Set("hash", "") + if parsed.Fragment != "" { + urlObj.Set("hash", "#"+parsed.Fragment) + } + urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host) + urlObj.Set("username", parsed.User.Username()) + password, _ := parsed.User.Password() + urlObj.Set("password", password) + + // searchParams object + searchParams := vm.NewObject() + queryValues := parsed.Query() + + searchParams.Set("get", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Null() + } + key := call.Arguments[0].String() + if val := queryValues.Get(key); val != "" { + return vm.ToValue(val) + } + return goja.Null() + }) + + searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue([]string{}) + } + key := call.Arguments[0].String() + return vm.ToValue(queryValues[key]) + }) + + searchParams.Set("has", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue(false) + } + key := call.Arguments[0].String() + return vm.ToValue(queryValues.Has(key)) + }) + + searchParams.Set("toString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(queryValues.Encode()) + }) + + urlObj.Set("searchParams", searchParams) + + // toString method + urlObj.Set("toString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(parsed.String()) + }) + + // toJSON method + urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(parsed.String()) + }) + + return nil + }) + + // URLSearchParams constructor + vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object { + paramsObj := call.This + values := url.Values{} + + // Parse initial value if provided + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + init := call.Arguments[0].Export() + switch v := init.(type) { + case string: + // Parse query string + parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?")) + values = parsed + case map[string]interface{}: + for k, val := range v { + values.Set(k, fmt.Sprintf("%v", val)) + } + } + } + + paramsObj.Set("append", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 2 { + values.Add(call.Arguments[0].String(), call.Arguments[1].String()) + } + return goja.Undefined() + }) + + paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 1 { + values.Del(call.Arguments[0].String()) + } + return goja.Undefined() + }) + + paramsObj.Set("get", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Null() + } + if val := values.Get(call.Arguments[0].String()); val != "" { + return vm.ToValue(val) + } + return goja.Null() + }) + + paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue([]string{}) + } + return vm.ToValue(values[call.Arguments[0].String()]) + }) + + paramsObj.Set("has", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue(false) + } + return vm.ToValue(values.Has(call.Arguments[0].String())) + }) + + paramsObj.Set("set", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 2 { + values.Set(call.Arguments[0].String(), call.Arguments[1].String()) + } + return goja.Undefined() + }) + + paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(values.Encode()) + }) + + return nil + }) +} + +// registerJSONGlobal ensures JSON global is properly set up +func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) { + // JSON is already built-in to Goja, but we can enhance it + // This ensures JSON.parse and JSON.stringify work as expected + + // The built-in JSON object should already work, but let's verify + // and add any missing functionality if needed + jsonScript := ` + if (typeof JSON === 'undefined') { + var JSON = { + parse: function(text) { + return utils.parseJSON(text); + }, + stringify: function(value, replacer, space) { + return utils.stringifyJSON(value); + } + }; + } + ` + _, _ = vm.RunString(jsonScript) +} diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go new file mode 100644 index 00000000..a44bfd33 --- /dev/null +++ b/go_backend/extension_runtime_storage.go @@ -0,0 +1,381 @@ +// Package gobackend provides Storage and Credentials API for extension runtime +package gobackend + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/dop251/goja" +) + +// ==================== Storage API ==================== + +// getStoragePath returns the path to the extension's storage file +func (r *ExtensionRuntime) getStoragePath() string { + return filepath.Join(r.dataDir, "storage.json") +} + +// loadStorage loads the storage data from disk +func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) { + storagePath := r.getStoragePath() + data, err := os.ReadFile(storagePath) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]interface{}), nil + } + return nil, err + } + + var storage map[string]interface{} + if err := json.Unmarshal(data, &storage); err != nil { + return nil, err + } + + return storage, nil +} + +// saveStorage saves the storage data to disk +func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error { + storagePath := r.getStoragePath() + data, err := json.MarshalIndent(storage, "", " ") + if err != nil { + return err + } + + return os.WriteFile(storagePath, data, 0644) +} + +// storageGet retrieves a value from storage +func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Undefined() + } + + key := call.Arguments[0].String() + + storage, err := r.loadStorage() + if err != nil { + GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) + return goja.Undefined() + } + + value, exists := storage[key] + if !exists { + // Return default value if provided + if len(call.Arguments) > 1 { + return call.Arguments[1] + } + return goja.Undefined() + } + + return r.vm.ToValue(value) +} + +// storageSet stores a value in storage +func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(false) + } + + key := call.Arguments[0].String() + value := call.Arguments[1].Export() + + storage, err := r.loadStorage() + if err != nil { + GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + storage[key] = value + + if err := r.saveStorage(storage); err != nil { + GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + return r.vm.ToValue(true) +} + +// storageRemove removes a value from storage +func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + key := call.Arguments[0].String() + + storage, err := r.loadStorage() + if err != nil { + GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + delete(storage, key) + + if err := r.saveStorage(storage); err != nil { + GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + return r.vm.ToValue(true) +} + +// ==================== Credentials API (Encrypted Storage) ==================== + +// getCredentialsPath returns the path to the extension's encrypted credentials file +func (r *ExtensionRuntime) getCredentialsPath() string { + return filepath.Join(r.dataDir, ".credentials.enc") +} + +// 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 +func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { + credPath := r.getCredentialsPath() + data, err := os.ReadFile(credPath) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]interface{}), nil + } + return nil, err + } + + // Decrypt the data + 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) + } + + var creds map[string]interface{} + if err := json.Unmarshal(decrypted, &creds); err != nil { + return nil, err + } + + return creds, nil +} + +// saveCredentials encrypts and saves credentials to disk +func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { + data, err := json.Marshal(creds) + if err != nil { + return err + } + + // Encrypt the data + 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) + } + + credPath := r.getCredentialsPath() + return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions +} + +// credentialsStore stores an encrypted credential +func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "key and value are required", + }) + } + + key := call.Arguments[0].String() + value := call.Arguments[1].Export() + + creds, err := r.loadCredentials() + if err != nil { + GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + creds[key] = value + + if err := r.saveCredentials(creds); err != nil { + GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + }) +} + +// credentialsGet retrieves a decrypted credential +func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Undefined() + } + + key := call.Arguments[0].String() + + creds, err := r.loadCredentials() + if err != nil { + GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) + return goja.Undefined() + } + + value, exists := creds[key] + if !exists { + // Return default value if provided + if len(call.Arguments) > 1 { + return call.Arguments[1] + } + return goja.Undefined() + } + + return r.vm.ToValue(value) +} + +// credentialsRemove removes a credential +func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + key := call.Arguments[0].String() + + creds, err := r.loadCredentials() + if err != nil { + GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + delete(creds, key) + + if err := r.saveCredentials(creds); err != nil { + GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err) + return r.vm.ToValue(false) + } + + return r.vm.ToValue(true) +} + +// credentialsHas checks if a credential exists +func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(false) + } + + key := call.Arguments[0].String() + + creds, err := r.loadCredentials() + if err != nil { + return r.vm.ToValue(false) + } + + _, exists := creds[key] + return r.vm.ToValue(exists) +} + +// ==================== Crypto Utilities ==================== + +// encryptAES encrypts data using AES-GCM +func encryptAES(plaintext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +// decryptAES decrypts data using AES-GCM +func decryptAES(ciphertext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + + return plaintext, nil +} diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go new file mode 100644 index 00000000..cd3819c1 --- /dev/null +++ b/go_backend/extension_runtime_utils.go @@ -0,0 +1,372 @@ +// Package gobackend provides Utility functions for extension runtime +package gobackend + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/dop251/goja" +) + +// ==================== Utility Functions ==================== + +// base64Encode encodes a string to base64 +func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) +} + +// base64Decode decodes a base64 string +func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + decoded, err := base64.StdEncoding.DecodeString(input) + if err != nil { + return r.vm.ToValue("") + } + return r.vm.ToValue(string(decoded)) +} + +// md5Hash computes MD5 hash of a string +func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + hash := md5.Sum([]byte(input)) + return r.vm.ToValue(hex.EncodeToString(hash[:])) +} + +// sha256Hash computes SHA256 hash of a string +func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + hash := sha256.Sum256([]byte(input)) + return r.vm.ToValue(hex.EncodeToString(hash[:])) +} + +// hmacSHA256 computes HMAC-SHA256 of a message with a key +func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue("") + } + message := call.Arguments[0].String() + key := call.Arguments[1].String() + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(message)) + return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil))) +} + +// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result +func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue("") + } + message := call.Arguments[0].String() + key := call.Arguments[1].String() + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(message)) + return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil))) +} + +// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP) +// Arguments: message (string or array of bytes), key (string or array of bytes) +// Returns: array of bytes (for TOTP dynamic truncation) +func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue([]byte{}) + } + + // Get key - can be string or array of bytes + var keyBytes []byte + keyArg := call.Arguments[0].Export() + switch k := keyArg.(type) { + case string: + keyBytes = []byte(k) + case []interface{}: + keyBytes = make([]byte, len(k)) + for i, v := range k { + if num, ok := v.(int64); ok { + keyBytes[i] = byte(num) + } else if num, ok := v.(float64); ok { + keyBytes[i] = byte(int(num)) + } + } + default: + return r.vm.ToValue([]byte{}) + } + + // Get message - can be string or array of bytes + var msgBytes []byte + msgArg := call.Arguments[1].Export() + switch m := msgArg.(type) { + case string: + msgBytes = []byte(m) + case []interface{}: + msgBytes = make([]byte, len(m)) + for i, v := range m { + if num, ok := v.(int64); ok { + msgBytes[i] = byte(num) + } else if num, ok := v.(float64); ok { + msgBytes[i] = byte(int(num)) + } + } + default: + return r.vm.ToValue([]byte{}) + } + + mac := hmac.New(sha1.New, keyBytes) + mac.Write(msgBytes) + result := mac.Sum(nil) + + // Convert to array of numbers for JavaScript + jsArray := make([]interface{}, len(result)) + for i, b := range result { + jsArray[i] = int(b) + } + return r.vm.ToValue(jsArray) +} + +// parseJSON parses a JSON string +func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Undefined() + } + input := call.Arguments[0].String() + + var result interface{} + if err := json.Unmarshal([]byte(input), &result); err != nil { + GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err) + return goja.Undefined() + } + + return r.vm.ToValue(result) +} + +// stringifyJSON converts a value to JSON string +func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].Export() + + data, err := json.Marshal(input) + if err != nil { + GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err) + return r.vm.ToValue("") + } + + return r.vm.ToValue(string(data)) +} + +// ==================== Crypto Utilities for Extensions ==================== + +// cryptoEncrypt encrypts a string using AES-GCM (for extension use) +func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "plaintext and key are required", + }) + } + + plaintext := call.Arguments[0].String() + keyStr := call.Arguments[1].String() + + // Derive 32-byte key from provided key string + keyHash := sha256.Sum256([]byte(keyStr)) + + encrypted, err := encryptAES([]byte(plaintext), keyHash[:]) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "data": base64.StdEncoding.EncodeToString(encrypted), + }) +} + +// cryptoDecrypt decrypts a string using AES-GCM (for extension use) +func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "ciphertext and key are required", + }) + } + + ciphertextB64 := call.Arguments[0].String() + keyStr := call.Arguments[1].String() + + ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "invalid base64 ciphertext", + }) + } + + // Derive 32-byte key from provided key string + keyHash := sha256.Sum256([]byte(keyStr)) + + decrypted, err := decryptAES(ciphertext, keyHash[:]) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "data": string(decrypted), + }) +} + +// cryptoGenerateKey generates a random encryption key +func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value { + length := 32 // Default 256-bit key + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + if l, ok := call.Arguments[0].Export().(float64); ok { + length = int(l) + } + } + + key := make([]byte, length) + if _, err := rand.Read(key); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + + return r.vm.ToValue(map[string]interface{}{ + "success": true, + "key": base64.StdEncoding.EncodeToString(key), + "hex": hex.EncodeToString(key), + }) +} + +// ==================== Logging Functions ==================== + +func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { + msg := r.formatLogArgs(call.Arguments) + GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) + return goja.Undefined() +} + +func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value { + msg := r.formatLogArgs(call.Arguments) + GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg) + return goja.Undefined() +} + +func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value { + msg := r.formatLogArgs(call.Arguments) + GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg) + return goja.Undefined() +} + +func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value { + msg := r.formatLogArgs(call.Arguments) + GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg) + return goja.Undefined() +} + +func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string { + parts := make([]string, len(args)) + for i, arg := range args { + parts[i] = fmt.Sprintf("%v", arg.Export()) + } + return strings.Join(parts, " ") +} + +// ==================== Go Backend Wrappers ==================== + +func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + return r.vm.ToValue(sanitizeFilename(input)) +} + +// RegisterGoBackendAPIs adds more Go backend functions to the VM +func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { + gobackendObj := vm.Get("gobackend") + if gobackendObj == nil || goja.IsUndefined(gobackendObj) { + gobackendObj = vm.NewObject() + vm.Set("gobackend", gobackendObj) + } + + obj := gobackendObj.(*goja.Object) + + // Expose sanitizeFilename + obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue("") + } + return vm.ToValue(sanitizeFilename(call.Arguments[0].String())) + }) + + // Expose getAudioQuality + obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue(map[string]interface{}{ + "error": "file path is required", + }) + } + + filePath := call.Arguments[0].String() + quality, err := GetAudioQuality(filePath) + if err != nil { + return vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + return vm.ToValue(map[string]interface{}{ + "bitDepth": quality.BitDepth, + "sampleRate": quality.SampleRate, + "totalSamples": quality.TotalSamples, + }) + }) + + // Expose buildFilename + obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return vm.ToValue("") + } + + template := call.Arguments[0].String() + metadataObj := call.Arguments[1].Export() + + metadata, ok := metadataObj.(map[string]interface{}) + if !ok { + return vm.ToValue("") + } + + return vm.ToValue(buildFilenameFromTemplate(template, metadata)) + }) +} diff --git a/go_backend/extension_settings.go b/go_backend/extension_settings.go new file mode 100644 index 00000000..6f46773c --- /dev/null +++ b/go_backend/extension_settings.go @@ -0,0 +1,221 @@ +// Package gobackend provides extension settings storage +package gobackend + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// ExtensionSettingsStore manages settings for all extensions +type ExtensionSettingsStore struct { + mu sync.RWMutex + dataDir string + settings map[string]map[string]interface{} // extensionID -> settings +} + +// Global settings store +var ( + globalSettingsStore *ExtensionSettingsStore + globalSettingsStoreOnce sync.Once +) + +// GetExtensionSettingsStore returns the global settings store +func GetExtensionSettingsStore() *ExtensionSettingsStore { + globalSettingsStoreOnce.Do(func() { + globalSettingsStore = &ExtensionSettingsStore{ + settings: make(map[string]map[string]interface{}), + } + }) + return globalSettingsStore +} + +// SetDataDir sets the data directory for settings storage +func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.dataDir = dataDir + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("failed to create settings directory: %w", err) + } + + // Load all existing settings + return s.loadAllSettings() +} + +// getSettingsPath returns the path to an extension's settings file +func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string { + return filepath.Join(s.dataDir, extensionID, "settings.json") +} + +// loadAllSettings loads settings for all extensions from disk +func (s *ExtensionSettingsStore) loadAllSettings() error { + entries, err := os.ReadDir(s.dataDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + extensionID := entry.Name() + settings, err := s.loadSettings(extensionID) + if err != nil { + GoLog("[ExtensionSettings] Failed to load settings for %s: %v\n", extensionID, err) + continue + } + s.settings[extensionID] = settings + } + } + + return nil +} + +// loadSettings loads settings for a specific extension +func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) { + settingsPath := s.getSettingsPath(extensionID) + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]interface{}), nil + } + return nil, err + } + + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err != nil { + return nil, err + } + + return settings, nil +} + +// saveSettings saves settings for a specific extension +func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error { + settingsPath := s.getSettingsPath(extensionID) + + // Create directory if needed + dir := filepath.Dir(settingsPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + + return os.WriteFile(settingsPath, data, 0644) +} + +// Get retrieves a setting value for an extension +// Returns error if extension or key not found (gomobile compatible) +func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + extSettings, exists := s.settings[extensionID] + if !exists { + return nil, fmt.Errorf("extension '%s' settings not found", extensionID) + } + + value, exists := extSettings[key] + if !exists { + return nil, fmt.Errorf("setting '%s' not found for extension '%s'", key, extensionID) + } + return value, nil +} + +// GetAll retrieves all settings for an extension +func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} { + s.mu.RLock() + defer s.mu.RUnlock() + + extSettings, exists := s.settings[extensionID] + if !exists { + return make(map[string]interface{}) + } + + // Return a copy + result := make(map[string]interface{}) + for k, v := range extSettings { + result[k] = v + } + return result +} + +// Set stores a setting value for an extension +func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.settings[extensionID]; !exists { + s.settings[extensionID] = make(map[string]interface{}) + } + + s.settings[extensionID][key] = value + + // Persist to disk + return s.saveSettings(extensionID, s.settings[extensionID]) +} + +// SetAll stores all settings for an extension +func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.settings[extensionID] = settings + + // Persist to disk + return s.saveSettings(extensionID, settings) +} + +// Remove removes a setting for an extension +func (s *ExtensionSettingsStore) Remove(extensionID, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + + extSettings, exists := s.settings[extensionID] + if !exists { + return nil + } + + delete(extSettings, key) + + // Persist to disk + return s.saveSettings(extensionID, extSettings) +} + +// RemoveAll removes all settings for an extension +func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.settings, extensionID) + + // Remove settings file + settingsPath := s.getSettingsPath(extensionID) + if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) { + return err + } + + return nil +} + +// GetAllExtensionSettings returns settings for all extensions as JSON +func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := json.Marshal(s.settings) + if err != nil { + return "", err + } + + return string(data), nil +} diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go new file mode 100644 index 00000000..2a2e1097 --- /dev/null +++ b/go_backend/extension_store.go @@ -0,0 +1,453 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +// Extension categories +const ( + CategoryMetadata = "metadata" + CategoryDownload = "download" + CategoryUtility = "utility" + CategoryLyrics = "lyrics" + CategoryIntegration = "integration" +) + +// StoreExtension represents an extension in the store +type StoreExtension struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + DownloadURL string `json:"download_url,omitempty"` + IconURL string `json:"icon_url,omitempty"` + Category string `json:"category"` + Tags []string `json:"tags,omitempty"` + Downloads int `json:"downloads"` + UpdatedAt string `json:"updated_at"` + MinAppVersion string `json:"min_app_version,omitempty"` + // Alternative camelCase fields (for flexibility) + DisplayNameAlt string `json:"displayName,omitempty"` + DownloadURLAlt string `json:"downloadUrl,omitempty"` + IconURLAlt string `json:"iconUrl,omitempty"` + MinAppVersionAlt string `json:"minAppVersion,omitempty"` +} + +// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict) +func (e *StoreExtension) getDisplayName() string { + if e.DisplayName != "" { + return e.DisplayName + } + if e.DisplayNameAlt != "" { + return e.DisplayNameAlt + } + return e.Name +} + +// getDownloadURL returns download URL from either field (private to avoid gomobile conflict) +func (e *StoreExtension) getDownloadURL() string { + if e.DownloadURL != "" { + return e.DownloadURL + } + return e.DownloadURLAlt +} + +// getIconURL returns icon URL from either field (private to avoid gomobile conflict) +func (e *StoreExtension) getIconURL() string { + if e.IconURL != "" { + return e.IconURL + } + return e.IconURLAlt +} + +// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict) +func (e *StoreExtension) getMinAppVersion() string { + if e.MinAppVersion != "" { + return e.MinAppVersion + } + return e.MinAppVersionAlt +} + +// StoreRegistry represents the extension registry +type StoreRegistry struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` + Extensions []StoreExtension `json:"extensions"` +} + +// StoreExtensionResponse is the normalized response sent to Flutter +type StoreExtensionResponse struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + DownloadURL string `json:"download_url"` + IconURL string `json:"icon_url,omitempty"` + Category string `json:"category"` + Tags []string `json:"tags,omitempty"` + Downloads int `json:"downloads"` + UpdatedAt string `json:"updated_at"` + MinAppVersion string `json:"min_app_version,omitempty"` + IsInstalled bool `json:"is_installed"` + InstalledVersion string `json:"installed_version,omitempty"` + HasUpdate bool `json:"has_update"` +} + +// ToResponse converts StoreExtension to normalized response +func (e *StoreExtension) ToResponse() StoreExtensionResponse { + return StoreExtensionResponse{ + ID: e.ID, + Name: e.Name, + DisplayName: e.getDisplayName(), + Version: e.Version, + Author: e.Author, + Description: e.Description, + DownloadURL: e.getDownloadURL(), + IconURL: e.getIconURL(), + Category: e.Category, + Tags: e.Tags, + Downloads: e.Downloads, + UpdatedAt: e.UpdatedAt, + MinAppVersion: e.getMinAppVersion(), + } +} + +// ExtensionStore manages the extension store +type ExtensionStore struct { + registryURL string + cacheDir string + cache *StoreRegistry + cacheMu sync.RWMutex + cacheTime time.Time + cacheTTL time.Duration +} + +var ( + extensionStore *ExtensionStore + extensionStoreMu sync.Mutex +) + +const ( + defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json" + cacheTTL = 30 * time.Minute + cacheFileName = "store_cache.json" +) + +// InitExtensionStore initializes the extension store +func InitExtensionStore(cacheDir string) *ExtensionStore { + extensionStoreMu.Lock() + defer extensionStoreMu.Unlock() + + if extensionStore == nil { + extensionStore = &ExtensionStore{ + registryURL: defaultRegistryURL, + cacheDir: cacheDir, + cacheTTL: cacheTTL, + } + // Try to load from disk cache + extensionStore.loadDiskCache() + } + return extensionStore +} + +// GetExtensionStore returns the singleton store instance +func GetExtensionStore() *ExtensionStore { + extensionStoreMu.Lock() + defer extensionStoreMu.Unlock() + return extensionStore +} + +// loadDiskCache loads cached registry from disk +func (s *ExtensionStore) loadDiskCache() { + if s.cacheDir == "" { + return + } + + cachePath := filepath.Join(s.cacheDir, cacheFileName) + data, err := os.ReadFile(cachePath) + if err != nil { + return + } + + var cacheData struct { + Registry StoreRegistry `json:"registry"` + CacheTime int64 `json:"cache_time"` + } + + if err := json.Unmarshal(data, &cacheData); err != nil { + return + } + + s.cache = &cacheData.Registry + s.cacheTime = time.Unix(cacheData.CacheTime, 0) + LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) +} + +// saveDiskCache saves registry to disk cache +func (s *ExtensionStore) saveDiskCache() { + if s.cacheDir == "" || s.cache == nil { + return + } + + cacheData := struct { + Registry StoreRegistry `json:"registry"` + CacheTime int64 `json:"cache_time"` + }{ + Registry: *s.cache, + CacheTime: s.cacheTime.Unix(), + } + + data, err := json.Marshal(cacheData) + if err != nil { + return + } + + cachePath := filepath.Join(s.cacheDir, cacheFileName) + os.WriteFile(cachePath, data, 0644) +} + +// FetchRegistry fetches the extension registry from GitHub +func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + // Return cached if valid and not forcing refresh + if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL { + LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions)) + return s.cache, nil + } + + LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(s.registryURL) + if err != nil { + // Return cached data if available on network error + if s.cache != nil { + LogWarn("ExtensionStore", "Network error, using cached registry: %v", err) + return s.cache, nil + } + return nil, fmt.Errorf("failed to fetch registry: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if s.cache != nil { + LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode) + return s.cache, nil + } + return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read registry: %w", err) + } + + var registry StoreRegistry + if err := json.Unmarshal(body, ®istry); err != nil { + return nil, fmt.Errorf("failed to parse registry: %w", err) + } + + s.cache = ®istry + s.cacheTime = time.Now() + s.saveDiskCache() + + LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions)) + return ®istry, nil +} + +// GetExtensionsWithStatus returns extensions with installation status +func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { + registry, err := s.FetchRegistry(false) + if err != nil { + return nil, err + } + + manager := GetExtensionManager() + installed := make(map[string]string) // id -> version + + if manager != nil { + for _, ext := range manager.GetAllExtensions() { + installed[ext.ID] = ext.Manifest.Version + } + } + + result := make([]StoreExtensionResponse, len(registry.Extensions)) + for i, ext := range registry.Extensions { + resp := ext.ToResponse() + + if installedVersion, ok := installed[ext.ID]; ok { + resp.IsInstalled = true + resp.InstalledVersion = installedVersion + resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 + } + + result[i] = resp + } + + return result, nil +} + +// DownloadExtension downloads an extension package to the specified path +func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { + registry, err := s.FetchRegistry(false) + if err != nil { + return err + } + + var ext *StoreExtension + for _, e := range registry.Extensions { + if e.ID == extensionID { + ext = &e + break + } + } + + if ext == nil { + return fmt.Errorf("extension %s not found in store", extensionID) + } + + LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Get(ext.getDownloadURL()) + if err != nil { + return fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned HTTP %d", resp.StatusCode) + } + + // Create destination file + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + os.Remove(destPath) + return fmt.Errorf("failed to write file: %w", err) + } + + LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath) + return nil +} + +// GetCategories returns all available categories +func (s *ExtensionStore) GetCategories() []string { + return []string{ + CategoryMetadata, + CategoryDownload, + CategoryUtility, + CategoryLyrics, + CategoryIntegration, + } +} + +// SearchExtensions searches extensions by query +func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { + extensions, err := s.GetExtensionsWithStatus() + if err != nil { + return nil, err + } + + if query == "" && category == "" { + return extensions, nil + } + + var result []StoreExtensionResponse + queryLower := toLower(query) + + for _, ext := range extensions { + // Filter by category + if category != "" && ext.Category != category { + continue + } + + // Filter by query + if query != "" { + if !containsIgnoreCase(ext.Name, queryLower) && + !containsIgnoreCase(ext.DisplayName, queryLower) && + !containsIgnoreCase(ext.Description, queryLower) && + !containsIgnoreCase(ext.Author, queryLower) { + // Check tags + found := false + for _, tag := range ext.Tags { + if containsIgnoreCase(tag, queryLower) { + found = true + break + } + } + if !found { + continue + } + } + } + + result = append(result, ext) + } + + return result, nil +} + +// ClearCache clears the in-memory and disk cache +func (s *ExtensionStore) ClearCache() { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + s.cache = nil + s.cacheTime = time.Time{} + + if s.cacheDir != "" { + cachePath := filepath.Join(s.cacheDir, cacheFileName) + os.Remove(cachePath) + } + + LogInfo("ExtensionStore", "Cache cleared") +} + +// Helper: case-insensitive contains +func containsIgnoreCase(s, substr string) bool { + return containsStr(toLower(s), substr) +} + +func toLower(s string) string { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + result[i] = c + } + return string(result) +} + +func containsStr(s, substr string) bool { + return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0) +} + +func findSubstring(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/go_backend/extension_test.go b/go_backend/extension_test.go new file mode 100644 index 00000000..4045279c --- /dev/null +++ b/go_backend/extension_test.go @@ -0,0 +1,329 @@ +package gobackend + +import ( + "path/filepath" + "testing" + + "github.com/dop251/goja" +) + +func TestParseManifest_Valid(t *testing.T) { + validManifest := `{ + "name": "test-provider", + "displayName": "Test Provider", + "version": "1.0.0", + "author": "Test Author", + "description": "A test extension", + "type": ["metadata_provider"], + "permissions": { + "network": ["api.test.com"], + "storage": true + } + }` + + manifest, err := ParseManifest([]byte(validManifest)) + if err != nil { + t.Fatalf("Expected valid manifest to parse, got error: %v", err) + } + + if manifest.Name != "test-provider" { + t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name) + } + + if manifest.Version != "1.0.0" { + t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version) + } + + if !manifest.IsMetadataProvider() { + t.Error("Expected IsMetadataProvider() to return true") + } + + if manifest.IsDownloadProvider() { + t.Error("Expected IsDownloadProvider() to return false") + } +} + +func TestParseManifest_MissingName(t *testing.T) { + invalidManifest := `{ + "version": "1.0.0", + "author": "Test Author", + "description": "A test extension", + "type": ["metadata_provider"] + }` + + _, err := ParseManifest([]byte(invalidManifest)) + if err == nil { + t.Fatal("Expected error for missing name") + } +} + +func TestParseManifest_MissingType(t *testing.T) { + invalidManifest := `{ + "name": "test-provider", + "version": "1.0.0", + "author": "Test Author", + "description": "A test extension" + }` + + _, err := ParseManifest([]byte(invalidManifest)) + if err == nil { + t.Fatal("Expected error for missing type") + } +} + +func TestIsDomainAllowed(t *testing.T) { + manifest := &ExtensionManifest{ + Permissions: ExtensionPermissions{ + Network: []string{"api.test.com", "*.example.com"}, + }, + } + + tests := []struct { + domain string + expected bool + }{ + {"api.test.com", true}, + {"api.example.com", true}, + {"sub.example.com", true}, + {"notallowed.com", false}, + {"test.com", false}, + } + + for _, tt := range tests { + result := manifest.IsDomainAllowed(tt.domain) + if result != tt.expected { + t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected) + } + } +} + +func TestExtensionRuntime_NetworkSandbox(t *testing.T) { + // Create a mock extension with limited network permissions + ext := &LoadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + Permissions: ExtensionPermissions{ + Network: []string{"api.allowed.com", "*.wildcard.com"}, + }, + }, + DataDir: t.TempDir(), + } + + runtime := NewExtensionRuntime(ext) + + // Test allowed domains + if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil { + t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err) + } + + if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil { + t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err) + } + + // Test blocked domains + if err := runtime.validateDomain("https://blocked.com/path"); err == nil { + t.Error("Expected blocked.com to be denied") + } + + if err := runtime.validateDomain("https://notallowed.com/path"); err == nil { + t.Error("Expected notallowed.com to be denied") + } +} + +func TestExtensionRuntime_FileSandbox(t *testing.T) { + tempDir := t.TempDir() + + ext := &LoadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + Permissions: ExtensionPermissions{ + File: true, // Enable file permission for test + }, + }, + DataDir: tempDir, + } + + runtime := NewExtensionRuntime(ext) + + // Test valid path within sandbox + validPath, err := runtime.validatePath("test.txt") + if err != nil { + t.Errorf("Expected relative path to be valid, got error: %v", err) + } + if validPath == "" { + t.Error("Expected non-empty path") + } + + // Test path traversal attack + _, err = runtime.validatePath("../../../etc/passwd") + if err == nil { + t.Error("Expected path traversal to be blocked") + } + + // Test nested path within sandbox (should be allowed) + nestedPath, err := runtime.validatePath("subdir/file.txt") + if err != nil { + t.Errorf("Expected nested path to be valid, got error: %v", err) + } + 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) { + ext := &LoadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + }, + DataDir: t.TempDir(), + } + + runtime := NewExtensionRuntime(ext) + vm := goja.New() + runtime.RegisterAPIs(vm) + + // Test base64 encode/decode + result, err := vm.RunString(`utils.base64Encode("hello")`) + if err != nil { + t.Fatalf("base64Encode failed: %v", err) + } + if result.String() != "aGVsbG8=" { + t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String()) + } + + result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`) + if err != nil { + t.Fatalf("base64Decode failed: %v", err) + } + if result.String() != "hello" { + t.Errorf("Expected 'hello', got '%s'", result.String()) + } + + // Test MD5 + result, err = vm.RunString(`utils.md5("hello")`) + if err != nil { + t.Fatalf("md5 failed: %v", err) + } + if result.String() != "5d41402abc4b2a76b9719d911017c592" { + t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String()) + } + + // Test JSON parse/stringify + result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`) + if err != nil { + t.Fatalf("stringifyJSON failed: %v", err) + } + // JSON output may vary in order, just check it's valid + if result.String() == "" { + 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) + } + } +} diff --git a/go_backend/extension_timeout.go b/go_backend/extension_timeout.go new file mode 100644 index 00000000..a55f0464 --- /dev/null +++ b/go_backend/extension_timeout.go @@ -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 +} diff --git a/go_backend/go.mod b/go_backend/go.mod index fcf64720..500a85f8 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -5,14 +5,19 @@ go 1.24.0 toolchain go1.24.5 require ( + github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/go-flac v1.0.0 ) require ( + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.3.8 // indirect golang.org/x/tools v0.40.0 // indirect ) diff --git a/go_backend/go.sum b/go_backend/go.sum index c93680e0..68a7935e 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -1,14 +1,28 @@ +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI= github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g= golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 88b0d6cc..8686ad5f 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -22,12 +22,12 @@ import ( 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 - - chromeVersion := rand.Intn(25) + 100 // Chrome 100-124 + winMajor := rand.Intn(2) + 10 // Windows 10 or 11 + + chromeVersion := rand.Intn(25) + 100 // Chrome 100-124 chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500 - chromePatch := rand.Intn(65) + 60 // Patch 60-125 - + chromePatch := rand.Intn(65) + 60 // Patch 60-125 + 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", winMajor, @@ -39,46 +39,48 @@ func getRandomUserAgent() string { // getRandomMacUserAgent generates a random Mac Chrome User-Agent string // Alternative format matching referensi/backend/spotify_metadata.go exactly -func getRandomMacUserAgent() string { - macMajor := rand.Intn(4) + 11 // macOS 11-14 - macMinor := rand.Intn(5) + 4 // Minor 4-8 - webkitMajor := rand.Intn(7) + 530 - webkitMinor := rand.Intn(7) + 30 - chromeMajor := rand.Intn(25) + 80 - chromeBuild := rand.Intn(1500) + 3000 - chromePatch := rand.Intn(65) + 60 - safariMajor := rand.Intn(7) + 530 - safariMinor := rand.Intn(6) + 30 - - return fmt.Sprintf( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", - macMajor, - macMinor, - webkitMajor, - webkitMinor, - chromeMajor, - chromeBuild, - chromePatch, - safariMajor, - safariMinor, - ) -} +// Kept for potential future use +// func getRandomMacUserAgent() string { +// macMajor := rand.Intn(4) + 11 // macOS 11-14 +// macMinor := rand.Intn(5) + 4 // Minor 4-8 +// webkitMajor := rand.Intn(7) + 530 +// webkitMinor := rand.Intn(7) + 30 +// chromeMajor := rand.Intn(25) + 80 +// chromeBuild := rand.Intn(1500) + 3000 +// chromePatch := rand.Intn(65) + 60 +// safariMajor := rand.Intn(7) + 530 +// safariMinor := rand.Intn(6) + 30 +// +// return fmt.Sprintf( +// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", +// macMajor, +// macMinor, +// webkitMajor, +// webkitMinor, +// chromeMajor, +// chromeBuild, +// chromePatch, +// safariMajor, +// safariMinor, +// ) +// } // getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent -func getRandomDesktopUserAgent() string { - if rand.Intn(2) == 0 { - return getRandomUserAgent() // Windows - } - return getRandomMacUserAgent() // Mac -} +// Kept for potential future use +// func getRandomDesktopUserAgent() string { +// if rand.Intn(2) == 0 { +// return getRandomUserAgent() // Windows +// } +// 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 // 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 ) // Shared transport with connection pooling to prevent TCP exhaustion @@ -96,9 +98,9 @@ var sharedTransport = &http.Transport{ ExpectContinueTimeout: 1 * time.Second, DisableKeepAlives: false, // Enable keep-alives for connection reuse ForceAttemptHTTP2: true, - WriteBufferSize: 64 * 1024, // 64KB write buffer - ReadBufferSize: 64 * 1024, // 64KB read buffer - DisableCompression: true, // FLAC is already compressed + WriteBufferSize: 64 * 1024, // 64KB write buffer + ReadBufferSize: 64 * 1024, // 64KB read buffer + DisableCompression: true, // FLAC is already compressed } // Shared HTTP client for general requests (reuses connections) @@ -184,15 +186,15 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf resp, err := client.Do(reqCopy) if err != nil { lastErr = err - + // Check for ISP blocking on network errors if CheckAndLogISPBlocking(err, requestURL, "HTTP") { // Don't retry if ISP blocking is detected - it won't help return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP") } - + if attempt < config.MaxRetries { - GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n", + GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n", attempt+1, config.MaxRetries+1, err, delay) time.Sleep(delay) delay = calculateNextDelay(delay, config) @@ -227,13 +229,13 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf body, _ := io.ReadAll(resp.Body) resp.Body.Close() bodyStr := strings.ToLower(string(body)) - + // Check if response looks like ISP blocking page ispBlockingIndicators := []string{ "blocked", "forbidden", "access denied", "not available in your", "restricted", "censored", "unavailable for legal", "blocked by", } - + for _, indicator := range ispBlockingIndicators { if strings.Contains(bodyStr, indicator) { LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode) @@ -267,10 +269,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf // calculateNextDelay calculates the next delay with exponential backoff func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration { nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor) - if nextDelay > config.MaxDelay { - nextDelay = config.MaxDelay - } - return nextDelay + return min(nextDelay, config.MaxDelay) } // getRetryAfterDuration parses Retry-After header and returns duration @@ -481,7 +480,7 @@ func extractDomain(rawURL string) string { if rawURL == "" { return "unknown" } - + parsed, err := url.Parse(rawURL) if err != nil { // Try to extract domain manually @@ -492,7 +491,7 @@ func extractDomain(rawURL string) string { } return rawURL } - + if parsed.Host != "" { return parsed.Host } @@ -505,11 +504,11 @@ func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { if err == nil { return nil } - + if CheckAndLogISPBlocking(err, requestURL, tag) { domain := extractDomain(requestURL) return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err) } - + return err } diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 9d18b46f..b1aa66cc 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -250,29 +250,30 @@ func msToLRCTimestamp(ms int64) string { // convertToLRC converts lyrics to LRC format string (without metadata headers) // Use convertToLRCWithMetadata for full LRC with headers -func convertToLRC(lyrics *LyricsResponse) string { - if lyrics == nil || len(lyrics.Lines) == 0 { - return "" - } - - var builder strings.Builder - - if lyrics.SyncType == "LINE_SYNCED" { - for _, line := range lyrics.Lines { - timestamp := msToLRCTimestamp(line.StartTimeMs) - builder.WriteString(timestamp) - builder.WriteString(line.Words) - builder.WriteString("\n") - } - } else { - for _, line := range lyrics.Lines { - builder.WriteString(line.Words) - builder.WriteString("\n") - } - } - - return builder.String() -} +// Kept for potential future use +// func convertToLRC(lyrics *LyricsResponse) string { +// if lyrics == nil || len(lyrics.Lines) == 0 { +// return "" +// } +// +// var builder strings.Builder +// +// if lyrics.SyncType == "LINE_SYNCED" { +// for _, line := range lyrics.Lines { +// timestamp := msToLRCTimestamp(line.StartTimeMs) +// builder.WriteString(timestamp) +// builder.WriteString(line.Words) +// builder.WriteString("\n") +// } +// } else { +// for _, line := range lyrics.Lines { +// builder.WriteString(line.Words) +// builder.WriteString("\n") +// } +// } +// +// return builder.String() +// } // convertToLRCWithMetadata converts lyrics to LRC format with metadata headers // Includes [ti:], [ar:], [by:] headers diff --git a/go_backend/metadata.go b/go_backend/metadata.go index a6daa4bc..3001730e 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -498,7 +498,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro } // 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 @@ -507,12 +507,12 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro 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:]...) @@ -570,7 +570,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 } diff --git a/go_backend/parallel.go b/go_backend/parallel.go index ffac2dc7..3eb7c8e2 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -233,7 +233,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size()) } -func preWarmTidalCache(isrc, trackName, artistName string) { +func preWarmTidalCache(isrc, _, _ string) { downloader := NewTidalDownloader() track, err := downloader.SearchTrackByISRC(isrc) if err == nil && track != nil { @@ -272,7 +272,7 @@ 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 return nil } diff --git a/go_backend/progress.go b/go_backend/progress.go index f1a0dfe1..1c95313e 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -23,7 +23,7 @@ type ItemProgress struct { ItemID string `json:"item_id"` BytesTotal int64 `json:"bytes_total"` BytesReceived int64 `json:"bytes_received"` - Progress float64 `json:"progress"` // 0.0 to 1.0 + Progress float64 `json:"progress"` // 0.0 to 1.0 SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s IsDownloading bool `json:"is_downloading"` Status string `json:"status"` // "downloading", "finalizing", "completed" @@ -204,11 +204,12 @@ func setDownloadDir(path string) error { } // getDownloadDir returns the default download directory -func getDownloadDir() string { - downloadDirMu.RLock() - defer downloadDirMu.RUnlock() - return downloadDir -} +// Kept for potential future use +// func getDownloadDir() string { +// downloadDirMu.RLock() +// defer downloadDirMu.RUnlock() +// return downloadDir +// } // ItemProgressWriter wraps io.Writer to track download progress for a specific item type ItemProgressWriter struct { @@ -256,7 +257,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) { bytesInInterval := pw.current - pw.lastBytes speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed } - + SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps) pw.lastReported = pw.current pw.lastTime = now diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 8670312e..5ebd83df 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" "sync" + "time" ) // QobuzDownloader handles Qobuz downloads @@ -63,24 +64,27 @@ 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) + // Split expected artists by common separators (comma, feat, ft., &, and) + // e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura" + expectedArtists := qobuzSplitArtists(normExpected) + foundArtists := qobuzSplitArtists(normFound) - 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 - } - - // Check if first artist is contained in the other - if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { - return true + // Check if ANY expected artist matches ANY found artist + for _, exp := range expectedArtists { + for _, fnd := range foundArtists { + if exp == fnd { + return true + } + // Also check contains for partial matches + if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { + return true + } + // Check same words different order + if qobuzSameWordsUnordered(exp, fnd) { + GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) + return true + } + } } // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) @@ -95,6 +99,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)) @@ -271,14 +336,15 @@ func qobuzIsLatinScript(s string) bool { } // qobuzIsASCIIString checks if a string contains only ASCII characters -func qobuzIsASCIIString(s string) bool { - for _, r := range s { - if r > 127 { - return false - } - } - return true -} +// Kept for potential future use +// func qobuzIsASCIIString(s string) bool { +// for _, r := range s { +// if r > 127 { +// return false +// } +// } +// return true +// } // containsQueryQobuz checks if a query already exists in the list func containsQueryQobuz(queries []string, query string) bool { @@ -634,85 +700,132 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) } -// 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) { +// qobuzAPIResult holds the result from a parallel API request +type qobuzAPIResult struct { + apiURL string + downloadURL string + err error + duration time.Duration +} + +// getQobuzDownloadURLParallel requests download URL from all APIs in parallel +// "Siapa cepat dia dapat" - first successful response wins +func getQobuzDownloadURLParallel(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 + GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis)) + resultChan := make(chan qobuzAPIResult, len(apis)) + startTime := time.Now() + + // Start all requests in parallel 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) + go func(api string) { + reqStart := time.Now() - GoLog("[Qobuz] Trying: %s\n", reqURL) + client := &http.Client{ + Timeout: 15 * time.Second, + } - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) - continue - } + reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) - resp, err := DoRequestWithRetry(client, req, retryConfig) - if err != nil { - errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) - continue - } + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} + return + } - body, err := ReadResponseBody(resp) - resp.Body.Close() - if err != nil { - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error())) - continue - } + resp, err := client.Do(req) + if err != nil { + resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} + return + } + defer resp.Body.Close() - // 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 - } + if resp.StatusCode != 200 { + resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)} + return + } - // 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 - } + body, err := io.ReadAll(resp.Body) + if err != nil { + resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} + return + } - 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 - } + // Check if response is HTML (error page) + if len(body) > 0 && body[0] == '<' { + resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)} + return + } - if result.URL != "" { - GoLog("[Qobuz] Got download URL from: %s\n", apiURL) - return apiURL, result.URL, nil - } + // Check for error in JSON response + var errorResp struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" { + resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)} + return + } - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL in response")) + var result struct { + URL string `json:"url"` + } + if err := json.Unmarshal(body, &result); err != nil { + resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)} + return + } + + if result.URL != "" { + resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)} + return + } + + resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)} + }(apiURL) } + // Collect results - return first success + var errors []string + + for i := 0; i < len(apis); i++ { + result := <-resultChan + 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 + go func(remaining int) { + for j := 0; j < remaining; j++ { + <-resultChan + } + }(len(apis) - i - 1) + + GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime)) + 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) } -// GetDownloadURL gets download URL for a track - tries APIs sequentially +// 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) { apis := q.GetAvailableAPIs() if len(apis) == 0 { return "", fmt.Errorf("no Qobuz API available") } - _, downloadURL, err := getQobuzDownloadURLSequential(apis, trackID, quality) + // Use parallel approach - request from all APIs simultaneously + _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality) if err != nil { return "", err } diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 3e2d866c..37846633 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -2,7 +2,6 @@ package gobackend import ( "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -17,14 +16,14 @@ import ( ) const ( - spotifyTokenURL = "https://accounts.spotify.com/api/token" - playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" - albumBaseURL = "https://api.spotify.com/v1/albums/%s" - trackBaseURL = "https://api.spotify.com/v1/tracks/%s" - artistBaseURL = "https://api.spotify.com/v1/artists/%s" - artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" - searchBaseURL = "https://api.spotify.com/v1/search" - + spotifyTokenURL = "https://accounts.spotify.com/api/token" + playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" + albumBaseURL = "https://api.spotify.com/v1/albums/%s" + trackBaseURL = "https://api.spotify.com/v1/tracks/%s" + artistBaseURL = "https://api.spotify.com/v1/artists/%s" + artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" + searchBaseURL = "https://api.spotify.com/v1/search" + // Cache TTL settings artistCacheTTL = 10 * time.Minute searchCacheTTL = 5 * time.Minute @@ -54,7 +53,7 @@ type SpotifyMetadataClient struct { rng *rand.Rand rngMu sync.Mutex userAgent string - + // Caches to reduce API calls artistCache map[string]*cacheEntry // key: artistID searchCache map[string]*cacheEntry // key: query+type @@ -69,8 +68,10 @@ var ( credentialsMu sync.RWMutex ) +// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured +var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)") + // SetSpotifyCredentials sets custom Spotify API credentials -// Pass empty strings to use default credentials func SetSpotifyCredentials(clientID, clientSecret string) { credentialsMu.Lock() defer credentialsMu.Unlock() @@ -78,39 +79,56 @@ func SetSpotifyCredentials(clientID, clientSecret string) { customClientSecret = clientSecret } -// getCredentials returns the current credentials (custom or default) -func getCredentials() (string, string) { +// HasSpotifyCredentials checks if Spotify credentials are configured +func HasSpotifyCredentials() bool { credentialsMu.RLock() defer credentialsMu.RUnlock() - + + // Check custom credentials first if customClientID != "" && customClientSecret != "" { - return customClientID, customClientSecret - } - - // Fall back to default credentials - clientID := os.Getenv("SPOTIFY_CLIENT_ID") - if clientID == "" { - if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil { - clientID = string(decoded) - } + return true } - clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") - if clientSecret == "" { - if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil { - clientSecret = string(decoded) - } + // Check environment variables + if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" { + return true } - - return clientID, clientSecret + + return false +} + +// getCredentials returns the current credentials or error if not configured +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") + + if clientID != "" && clientSecret != "" { + return clientID, clientSecret, nil + } + + // No credentials available + return "", "", ErrNoSpotifyCredentials } // NewSpotifyMetadataClient creates a new Spotify client -func NewSpotifyMetadataClient() *SpotifyMetadataClient { - src := rand.NewSource(time.Now().UnixNano()) +// 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 + } - // Get credentials (custom or default) - clientID, clientSecret := getCredentials() + src := rand.NewSource(time.Now().UnixNano()) c := &SpotifyMetadataClient{ httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling @@ -122,7 +140,7 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient { albumCache: make(map[string]*cacheEntry), } c.userAgent = c.randomUserAgent() - return c + return c, nil } // TrackMetadata represents track information @@ -140,6 +158,7 @@ type TrackMetadata struct { DiscNumber int `json:"disc_number,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` + AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation } // AlbumTrackMetadata holds per-track info for album/playlist @@ -159,6 +178,7 @@ type AlbumTrackMetadata struct { ISRC string `json:"isrc"` AlbumID string `json:"album_id,omitempty"` AlbumURL string `json:"album_url,omitempty"` + AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation } // AlbumInfoMetadata holds album information @@ -283,6 +303,7 @@ type albumSimplified struct { Images []image `json:"images"` ExternalURL externalURL `json:"external_urls"` Artists []artist `json:"artists"` + AlbumType string `json:"album_type"` // album, single, compilation } type trackFull struct { @@ -331,14 +352,14 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, } searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit) - + var response struct { Tracks struct { Items []trackFull `json:"items"` Total int `json:"total"` } `json:"tracks"` } - + if err := c.getJSON(ctx, searchURL, token, &response); err != nil { return nil, err } @@ -363,6 +384,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, DiscNumber: track.DiscNumber, ExternalURL: track.ExternalURL.Spotify, ISRC: track.ExternalID.ISRC, + AlbumType: track.Album.AlbumType, }) } @@ -373,7 +395,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, 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() { @@ -388,24 +410,24 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra } searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit) - + var response struct { Tracks struct { Items []trackFull `json:"items"` } `json:"tracks"` Artists struct { Items []struct { - ID string `json:"id"` - Name string `json:"name"` - Images []image `json:"images"` - Followers struct { + ID string `json:"id"` + Name string `json:"name"` + Images []image `json:"images"` + Followers struct { Total int `json:"total"` } `json:"followers"` Popularity int `json:"popularity"` } `json:"items"` } `json:"artists"` } - + if err := c.getJSON(ctx, searchURL, token, &response); err != nil { return nil, err } @@ -430,6 +452,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra DiscNumber: track.DiscNumber, ExternalURL: track.ExternalURL.Spotify, ISRC: track.ExternalID.ISRC, + AlbumType: track.Album.AlbumType, }) } @@ -438,7 +461,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra if artistCount > artistLimit { artistCount = artistLimit } - + for i := 0; i < artistCount; i++ { artist := response.Artists.Items[i] result.Artists = append(result.Artists, SearchArtistResult{ @@ -534,7 +557,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s // Collect all tracks (including paginated) allTrackItems := data.Tracks.Items nextURL := data.Tracks.Next - + // Fetch remaining tracks using pagination (no limit) for nextURL != "" { var pageData struct { @@ -563,7 +586,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems)) for _, item := range allTrackItems { isrc := isrcMap[item.ID] - + tracks = append(tracks, AlbumTrackMetadata{ SpotifyID: item.ID, Artists: joinArtists(item.Artists), @@ -602,23 +625,23 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s // Similar to Deezer implementation for consistency func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string { const maxParallelISRC = 10 // Max concurrent ISRC fetches - + result := make(map[string]string) var resultMu sync.Mutex - + if len(trackIDs) == 0 { return result } - + // Use semaphore to limit concurrent requests sem := make(chan struct{}, maxParallelISRC) var wg sync.WaitGroup - + for _, trackID := range trackIDs { wg.Add(1) go func(id string) { defer wg.Done() - + // Acquire semaphore select { case sem <- struct{}{}: @@ -626,15 +649,15 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs case <-ctx.Done(): return } - + isrc := c.fetchTrackISRC(ctx, id, token) - + resultMu.Lock() result[id] = isrc resultMu.Unlock() }(trackID) } - + wg.Wait() return result } @@ -668,7 +691,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t // Pre-allocate with expected capacity tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total) - + // Add first batch of tracks for _, item := range data.Tracks.Items { if item.Track == nil { @@ -695,7 +718,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t // Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks) nextURL := data.Tracks.Next - + for nextURL != "" { var pageData struct { Items []struct { @@ -755,10 +778,10 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token // Fetch artist info var artistData struct { - ID string `json:"id"` - Name string `json:"name"` - Images []image `json:"images"` - Followers struct { + ID string `json:"id"` + Name string `json:"name"` + Images []image `json:"images"` + Followers struct { Total int `json:"total"` } `json:"followers"` Popularity int `json:"popularity"` @@ -941,15 +964,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string { defer c.rngMu.Unlock() // Use Mac User-Agent format (same as PC version) - macMajor := c.rng.Intn(4) + 11 // 11-14 - macMinor := c.rng.Intn(5) + 4 // 4-8 - webkitMajor := c.rng.Intn(7) + 530 // 530-536 - webkitMinor := c.rng.Intn(7) + 30 // 30-36 - chromeMajor := c.rng.Intn(25) + 80 // 80-104 + macMajor := c.rng.Intn(4) + 11 // 11-14 + macMinor := c.rng.Intn(5) + 4 // 4-8 + webkitMajor := c.rng.Intn(7) + 530 // 530-536 + webkitMinor := c.rng.Intn(7) + 30 // 30-36 + chromeMajor := c.rng.Intn(25) + 80 // 80-104 chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499 - chromePatch := c.rng.Intn(65) + 60 // 60-124 - safariMajor := c.rng.Intn(7) + 530 // 530-536 - safariMinor := c.rng.Intn(6) + 30 // 30-35 + chromePatch := c.rng.Intn(65) + 60 // 60-124 + safariMajor := c.rng.Intn(7) + 530 // 530-536 + safariMinor := c.rng.Intn(6) + 30 // 30-35 return fmt.Sprintf( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", diff --git a/go_backend/tidal.go b/go_backend/tidal.go index fba8552a..a6663827 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -345,27 +345,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } -// normalizeTitle normalizes a track title for comparison (kept for potential future use) -func normalizeTitle(title string) string { - normalized := strings.ToLower(strings.TrimSpace(title)) - - // Remove common suffixes in parentheses or brackets - suffixPatterns := []string{ - " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)", - " (bonus track)", " (single)", " (album version)", " (radio edit)", - " [remaster]", " [remastered]", " [deluxe]", " [bonus track]", - } - for _, suffix := range suffixPatterns { - normalized = strings.TrimSuffix(normalized, suffix) - } - - // Remove multiple spaces - for strings.Contains(normalized, " ") { - normalized = strings.ReplaceAll(normalized, " ", " ") - } - - return normalized -} +// normalizeTitle normalizes a track title for comparison +// Kept for potential future use +// func normalizeTitle(title string) string { +// normalized := strings.ToLower(strings.TrimSpace(title)) +// +// // Remove common suffixes in parentheses or brackets +// suffixPatterns := []string{ +// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)", +// " (bonus track)", " (single)", " (album version)", " (radio edit)", +// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]", +// } +// for _, suffix := range suffixPatterns { +// normalized = strings.TrimSuffix(normalized, suffix) +// } +// +// // Remove multiple spaces +// for strings.Contains(normalized, " ") { +// normalized = strings.ReplaceAll(normalized, " ", " ") +// } +// +// return normalized +// } // SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority // Now includes romaji conversion for Japanese text (4 search strategies like PC) @@ -648,6 +649,7 @@ type tidalAPIResult struct { // getDownloadURLParallel requests download URL from all APIs in parallel // Returns the first successful result (supports both v1 and v2 API formats) +// "Siapa cepat dia dapat" - first success wins func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { if len(apis) == 0 { return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") @@ -663,38 +665,33 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin go func(api string) { reqStart := time.Now() - // Create client with longer timeout for parallel requests + // Create client with timeout for parallel requests client := &http.Client{ Timeout: 15 * time.Second, } reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) - GoLog("[Tidal] [Parallel] Starting request to: %s\n", api) req, err := http.NewRequest("GET", reqURL, nil) if err != nil { - GoLog("[Tidal] [Parallel] %s - Failed to create request: %v\n", api, err) resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} return } resp, err := client.Do(req) if err != nil { - GoLog("[Tidal] [Parallel] %s - Request failed: %v\n", api, err) resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} return } defer resp.Body.Close() if resp.StatusCode != 200 { - GoLog("[Tidal] [Parallel] %s - HTTP %d\n", api, resp.StatusCode) resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)} return } body, err := io.ReadAll(resp.Body) if err != nil { - GoLog("[Tidal] [Parallel] %s - Failed to read body: %v\n", api, err) resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} return } @@ -704,14 +701,10 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { // IMPORTANT: Reject PREVIEW responses - we need FULL tracks if v2Response.Data.AssetPresentation == "PREVIEW" { - GoLog("[Tidal] [Parallel] %s - Rejecting PREVIEW response\n", api) resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} return } - GoLog("[Tidal] [Parallel] %s - Got FULL track (v2): %d-bit/%dHz in %v\n", - api, v2Response.Data.BitDepth, v2Response.Data.SampleRate, time.Since(reqStart)) - info := TidalDownloadInfo{ URL: "MANIFEST:" + v2Response.Data.Manifest, BitDepth: v2Response.Data.BitDepth, @@ -728,7 +721,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin if err := json.Unmarshal(body, &v1Responses); err == nil { for _, item := range v1Responses { if item.OriginalTrackURL != "" { - GoLog("[Tidal] [Parallel] %s - Got direct URL (v1) in %v\n", api, time.Since(reqStart)) info := TidalDownloadInfo{ URL: item.OriginalTrackURL, BitDepth: 16, @@ -740,148 +732,51 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin } } - GoLog("[Tidal] [Parallel] %s - No download URL in response\n", api) resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)} }(apiURL) } // Collect results - return first success var errors []string - successCount := 0 - failCount := 0 for i := 0; i < len(apis); i++ { result := <-resultChan if result.err == nil { - successCount++ - if successCount == 1 { - // First success - use this one - GoLog("[Tidal] [Parallel] ✓ Using response from %s (took %v, total %v)\n", - result.apiURL, result.duration, time.Since(startTime)) + // First success - use this one + GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n", + result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration) - // Don't return immediately - let other goroutines finish to avoid leaks - // But we'll use this result - go func() { - // Drain remaining results - for j := i + 1; j < len(apis); j++ { - <-resultChan - } - }() + // Don't return immediately - drain remaining results to avoid goroutine leaks + go func(remaining int) { + for j := 0; j < remaining; j++ { + <-resultChan + } + }(len(apis) - i - 1) - return result.apiURL, result.info, nil - } - } else { - failCount++ - errMsg := result.err.Error() - if len(errMsg) > 50 { - errMsg = errMsg[:50] + "..." - } - errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) - GoLog("[Tidal] [Parallel] ✗ %s failed: %s (took %v)\n", result.apiURL, errMsg, result.duration) + GoLog("[Tidal] [Parallel] Total time: %v (first success)\n", time.Since(startTime)) + 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 APIs sequentially +// 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) { apis := t.GetAvailableAPIs() if len(apis) == 0 { return TidalDownloadInfo{}, fmt.Errorf("no API URL configured") } - _, info, err := getDownloadURLSequential(apis, trackID, quality) + // 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) } @@ -1266,24 +1161,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) @@ -1299,6 +1197,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)) @@ -1473,14 +1432,15 @@ func isLatinScript(s string) bool { } // isASCIIString checks if a string contains only ASCII characters -func isASCIIString(s string) bool { - for _, r := range s { - if r > 127 { - return false - } - } - return true -} +// Kept for potential future use +// func isASCIIString(s string) bool { +// for _, r := range s { +// if r > 127 { +// return false +// } +// } +// return true +// } // downloadFromTidal downloads a track using the request parameters func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { @@ -1532,7 +1492,7 @@ 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 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6af08b3c..b14c63a8 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -256,6 +256,10 @@ import Gobackend // Import Go framework GobackendSetSpotifyAPICredentials(clientId, clientSecret) return nil + case "hasSpotifyCredentials": + let hasCredentials = GobackendCheckSpotifyCredentials() + return hasCredentials + // Log methods case "getLogs": let response = GobackendGetLogs() @@ -281,6 +285,279 @@ import Gobackend // Import Go framework GobackendSetLoggingEnabled(enabled) return nil + // Extension System methods + case "initExtensionSystem": + let args = call.arguments as! [String: Any] + let extensionsDir = args["extensions_dir"] as! String + let dataDir = args["data_dir"] as! String + GobackendInitExtensionSystem(extensionsDir, dataDir, &error) + if let error = error { throw error } + return nil + + case "loadExtensionsFromDir": + let args = call.arguments as! [String: Any] + let dirPath = args["dir_path"] as! String + let response = GobackendLoadExtensionsFromDir(dirPath, &error) + if let error = error { throw error } + return response + + case "loadExtensionFromPath": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let response = GobackendLoadExtensionFromPath(filePath, &error) + if let error = error { throw error } + return response + + case "unloadExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + GobackendUnloadExtensionByID(extensionId, &error) + if let error = error { throw error } + return nil + + case "getInstalledExtensions": + let response = GobackendGetInstalledExtensions(&error) + if let error = error { throw error } + return response + + case "setExtensionEnabled": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let enabled = args["enabled"] as? Bool ?? false + GobackendSetExtensionEnabledByID(extensionId, enabled, &error) + if let error = error { throw error } + return nil + + case "setProviderPriority": + let args = call.arguments as! [String: Any] + let priorityJson = args["priority"] as! String + GobackendSetProviderPriorityJSON(priorityJson, &error) + if let error = error { throw error } + return nil + + case "getProviderPriority": + let response = GobackendGetProviderPriorityJSON(&error) + if let error = error { throw error } + return response + + case "setMetadataProviderPriority": + let args = call.arguments as! [String: Any] + let priorityJson = args["priority"] as! String + GobackendSetMetadataProviderPriorityJSON(priorityJson, &error) + if let error = error { throw error } + return nil + + case "getMetadataProviderPriority": + let response = GobackendGetMetadataProviderPriorityJSON(&error) + if let error = error { throw error } + return response + + case "getExtensionSettings": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendGetExtensionSettingsJSON(extensionId, &error) + if let error = error { throw error } + return response + + case "setExtensionSettings": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let settingsJson = args["settings"] as! String + GobackendSetExtensionSettingsJSON(extensionId, settingsJson, &error) + if let error = error { throw error } + return nil + + case "searchTracksWithExtensions": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let limit = args["limit"] as? Int ?? 20 + let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error) + if let error = error { throw error } + return response + + case "downloadWithExtensions": + let requestJson = call.arguments as! String + let response = GobackendDownloadWithExtensionsJSON(requestJson, &error) + if let error = error { throw error } + return response + + case "removeExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + GobackendRemoveExtensionByID(extensionId, &error) + if let error = error { throw error } + return nil + + case "upgradeExtension": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let response = GobackendUpgradeExtensionFromPath(filePath, &error) + if let error = error { throw error } + return response + + case "checkExtensionUpgrade": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let response = GobackendCheckExtensionUpgradeFromPath(filePath, &error) + if let error = error { throw error } + return response + + case "cleanupExtensions": + GobackendCleanupExtensions() + return nil + + // Extension Auth API + case "getExtensionPendingAuth": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendGetExtensionPendingAuthJSON(extensionId, &error) + if let error = error { throw error } + return response + + case "setExtensionAuthCode": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let authCode = args["auth_code"] as! String + GobackendSetExtensionAuthCodeByID(extensionId, authCode) + return nil + + case "setExtensionTokens": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let accessToken = args["access_token"] as! String + let refreshToken = args["refresh_token"] as? String ?? "" + let expiresIn = args["expires_in"] as? Int ?? 0 + GobackendSetExtensionTokensByID(extensionId, accessToken, refreshToken, Int(expiresIn)) + return nil + + case "clearExtensionPendingAuth": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + GobackendClearExtensionPendingAuthByID(extensionId) + return nil + + case "isExtensionAuthenticated": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendIsExtensionAuthenticatedByID(extensionId) + return response + + case "getAllPendingAuthRequests": + let response = GobackendGetAllPendingAuthRequestsJSON(&error) + if let error = error { throw error } + return response + + // Extension FFmpeg API + case "getPendingFFmpegCommand": + let args = call.arguments as! [String: Any] + let commandId = args["command_id"] as! String + let response = GobackendGetPendingFFmpegCommandJSON(commandId, &error) + if let error = error { throw error } + return response + + case "setFFmpegCommandResult": + let args = call.arguments as! [String: Any] + let commandId = args["command_id"] as! String + let success = args["success"] as? Bool ?? false + let output = args["output"] as? String ?? "" + let errorMsg = args["error"] as? String ?? "" + GobackendSetFFmpegCommandResult(commandId, success, output, errorMsg) + return nil + + case "getAllPendingFFmpegCommands": + let response = GobackendGetAllPendingFFmpegCommandsJSON(&error) + if let error = error { throw error } + return response + + // Extension Custom Search API + case "customSearchWithExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let query = args["query"] as! String + let optionsJson = args["options"] as? String ?? "" + let response = GobackendCustomSearchWithExtensionJSON(extensionId, query, optionsJson, &error) + if let error = error { throw error } + return response + + case "getSearchProviders": + let response = GobackendGetSearchProvidersJSON(&error) + if let error = error { throw error } + return response + + // Extension URL Handler API + case "handleURLWithExtension": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendHandleURLWithExtensionJSON(url, &error) + if let error = error { throw error } + return response + + case "findURLHandler": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendFindURLHandlerJSON(url) + return response + + case "getURLHandlers": + let response = GobackendGetURLHandlersJSON(&error) + if let error = error { throw error } + return response + + // Extension Post-Processing API + case "runPostProcessing": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let metadataJson = args["metadata"] as? String ?? "" + let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error) + if let error = error { throw error } + return response + + case "getPostProcessingProviders": + let response = GobackendGetPostProcessingProvidersJSON(&error) + 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", diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index d1e4cace..724afbe6 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '2.2.9'; - static const String buildNumber = '51'; + static const String version = '3.0.0'; + static const String buildNumber = '57'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/main.dart b/lib/main.dart index dbdb3dfd..e0ec75e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,10 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/app.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; @@ -24,14 +27,42 @@ void main() async { } /// Widget to eagerly initialize providers that need to load data on startup -class _EagerInitialization extends ConsumerWidget { +class _EagerInitialization extends ConsumerStatefulWidget { const _EagerInitialization({required this.child}); final Widget child; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<_EagerInitialization> createState() => _EagerInitializationState(); +} + +class _EagerInitializationState extends ConsumerState<_EagerInitialization> { + @override + void initState() { + super.initState(); + _initializeExtensions(); + } + + Future _initializeExtensions() async { + try { + final appDir = await getApplicationDocumentsDirectory(); + 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'); + } + } + + @override + Widget build(BuildContext context) { // Eagerly initialize download history provider to load from storage ref.watch(downloadHistoryProvider); - return child; + return widget.child; } } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index d3accfff..5462a4a3 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -25,6 +25,11 @@ class AppSettings { final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) final String metadataSource; // spotify, deezer - source for search and metadata final bool enableLogging; // Enable detailed logging for debugging + 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 or album_only + final bool showExtensionStore; // Show Extension Store tab in navigation const AppSettings({ this.defaultService = 'tidal', @@ -48,6 +53,11 @@ class AppSettings { this.useCustomSpotifyCredentials = true, // Default: use custom if set this.metadataSource = 'deezer', // Default: Deezer (no rate limit) this.enableLogging = false, // Default: disabled for performance + 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 }); AppSettings copyWith({ @@ -72,6 +82,12 @@ class AppSettings { bool? useCustomSpotifyCredentials, String? metadataSource, bool? enableLogging, + bool? useExtensionProviders, + String? searchProvider, + bool clearSearchProvider = false, // Set to true to clear searchProvider to null + bool? separateSingles, + String? albumFolderStructure, + bool? showExtensionStore, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -95,6 +111,11 @@ class AppSettings { useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, metadataSource: metadataSource ?? this.metadataSource, enableLogging: enableLogging ?? this.enableLogging, + useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, + searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider), + separateSingles: separateSingles ?? this.separateSingles, + albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, + showExtensionStore: showExtensionStore ?? this.showExtensionStore, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 6f2d172f..06cd85b7 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -29,6 +29,11 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( json['useCustomSpotifyCredentials'] as bool? ?? true, metadataSource: json['metadataSource'] as String? ?? 'deezer', enableLogging: json['enableLogging'] as bool? ?? false, + 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, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -54,4 +59,9 @@ Map _$AppSettingsToJson(AppSettings instance) => 'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials, 'metadataSource': instance.metadataSource, 'enableLogging': instance.enableLogging, + 'useExtensionProviders': instance.useExtensionProviders, + 'searchProvider': instance.searchProvider, + 'separateSingles': instance.separateSingles, + 'albumFolderStructure': instance.albumFolderStructure, + 'showExtensionStore': instance.showExtensionStore, }; diff --git a/lib/models/track.dart b/lib/models/track.dart index 7ae36ce7..ac579b50 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -18,6 +18,8 @@ class Track { final String? releaseDate; final String? deezerId; 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) const Track({ required this.id, @@ -33,10 +35,18 @@ class Track { this.releaseDate, this.deezerId, this.availability, + this.source, + this.albumType, }); + /// Check if this track is a single (based on album_type metadata) + bool get isSingle => albumType == 'single' || albumType == 'ep'; + factory Track.fromJson(Map json) => _$TrackFromJson(json); Map toJson() => _$TrackToJson(this); + + /// Check if this track is from an extension + bool get isFromExtension => source != null && source!.isNotEmpty; } @JsonSerializable() diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index ac78e26d..0836a5b2 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -24,6 +24,8 @@ Track _$TrackFromJson(Map json) => Track( : ServiceAvailability.fromJson( json['availability'] as Map, ), + source: json['source'] as String?, + albumType: json['albumType'] as String?, ); Map _$TrackToJson(Track instance) => { @@ -40,6 +42,8 @@ Map _$TrackToJson(Track instance) => { 'releaseDate': instance.releaseDate, 'deezerId': instance.deezerId, 'availability': instance.availability, + 'source': instance.source, + 'albumType': instance.albumType, }; ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 7d75543d..ccbab4a8 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; @@ -667,35 +668,64 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: dir); } - /// Build output directory based on folder organization setting - Future _buildOutputDir(Track track, String folderOrganization) async { + /// Build output directory based on folder organization setting and separateSingles + Future _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async { String baseDir = state.outputDir; - if (folderOrganization == 'none') { - return baseDir; + // If separateSingles is enabled, use Albums/Singles structure + if (separateSingles) { + final isSingle = track.isSingle; + + if (isSingle) { + // Singles go to Singles folder (flat structure) + final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; + final dir = Directory(singlesPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + _log.d('Created Singles folder: $singlesPath'); + } + return singlesPath; + } else { + // Albums folder structure based on setting + final albumName = _sanitizeFolderName(track.albumName); + String albumPath; + + if (albumFolderStructure == 'album_only') { + // Albums/Album structure (no artist folder) + albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName'; + } else { + // Albums/Artist/Album structure (default) + final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; + } + + final dir = Directory(albumPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + _log.d('Created Album folder: $albumPath'); + } + return albumPath; + } } - // Sanitize folder names (remove invalid characters) - String sanitize(String name) { - return name - .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') - .replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots - .trim(); + // Original folder organization logic (when separateSingles is disabled) + if (folderOrganization == 'none') { + return baseDir; } String subPath = ''; switch (folderOrganization) { case 'artist': - final artistName = sanitize(track.albumArtist ?? track.artistName); + final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); subPath = artistName; break; case 'album': - final albumName = sanitize(track.albumName); + final albumName = _sanitizeFolderName(track.albumName); subPath = albumName; break; case 'artist_album': - final artistName = sanitize(track.albumArtist ?? track.artistName); - final albumName = sanitize(track.albumName); + final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + final albumName = _sanitizeFolderName(track.albumName); subPath = '$artistName${Platform.pathSeparator}$albumName'; break; } @@ -713,6 +743,14 @@ class DownloadQueueNotifier extends Notifier { return baseDir; } + /// Sanitize folder names (remove invalid characters) + String _sanitizeFolderName(String name) { + return name + .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') + .replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots + .trim(); + } + void updateSettings(AppSettings settings) { state = state.copyWith( outputDir: settings.downloadDirectory.isNotEmpty @@ -922,13 +960,92 @@ class DownloadQueueNotifier extends Notifier { _saveQueueToStorage(); // Persist queue } + /// Run post-processing hooks on a downloaded file + Future _runPostProcessingHooks(String filePath, Track track) async { + try { + final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + + // Check if post-processing is enabled and there are extensions with hooks + if (!settings.useExtensionProviders) return; + + final hasPostProcessing = extensionState.extensions.any( + (e) => e.enabled && e.hasPostProcessing, + ); + if (!hasPostProcessing) return; + + _log.d('Running post-processing hooks on: $filePath'); + + // Build metadata map for post-processing + final metadata = { + 'title': track.name, + 'artist': track.artistName, + 'album': track.albumName, + 'album_artist': track.albumArtist ?? track.artistName, + 'track_number': track.trackNumber ?? 1, + 'disc_number': track.discNumber ?? 1, + 'isrc': track.isrc ?? '', + 'release_date': track.releaseDate ?? '', + 'duration_ms': track.duration * 1000, + 'cover_url': track.coverUrl ?? '', + }; + + final result = await PlatformBridge.runPostProcessing(filePath, metadata: metadata); + + if (result['success'] == true) { + final hooksRun = result['hooks_run'] as int? ?? 0; + final newPath = result['file_path'] as String?; + _log.i('Post-processing completed: $hooksRun hook(s) executed'); + + if (newPath != null && newPath != filePath) { + _log.d('File path changed by post-processing: $newPath'); + } + } else { + final error = result['error'] as String? ?? 'Unknown error'; + _log.w('Post-processing failed: $error'); + } + } catch (e) { + _log.w('Post-processing error: $e'); + // Don't fail the download if post-processing fails + } + } + + /// Upgrade Spotify cover URL to max quality (~2000x2000) + /// Same logic as Go backend cover.go + String _upgradeToMaxQualityCover(String coverUrl) { + const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small) + const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium) + const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000) + + // First upgrade small (300) to medium (640) + var result = coverUrl; + if (result.contains(spotifySize300)) { + result = result.replaceFirst(spotifySize300, spotifySize640); + } + + // Then upgrade medium (640) to max + if (result.contains(spotifySize640)) { + result = result.replaceFirst(spotifySize640, spotifySizeMax); + } + + return result; + } + /// Embed metadata and cover to a FLAC file after M4A conversion Future _embedMetadataAndCover(String flacPath, Track track) async { + final settings = ref.read(settingsProvider); + // Download cover first String? coverPath; - final coverUrl = track.coverUrl; + var coverUrl = track.coverUrl; if (coverUrl != null && coverUrl.isNotEmpty) { try { + // Upgrade cover URL to max quality if setting is enabled + if (settings.maxQualityCover) { + coverUrl = _upgradeToMaxQualityCover(coverUrl); + _log.d('Cover URL upgraded to max quality: $coverUrl'); + } + final tempDir = await getTemporaryDirectory(); final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; @@ -1366,6 +1483,8 @@ class DownloadQueueNotifier extends Notifier { final outputDir = await _buildOutputDir( trackToDownload, settings.folderOrganization, + separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, ); // Use quality override if set, otherwise use default from settings @@ -1373,7 +1492,37 @@ class DownloadQueueNotifier extends Notifier { Map result; - if (state.autoFallback) { + // Check if extension providers should be used + final extensionState = ref.read(extensionProvider); + final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled); + final useExtensions = settings.useExtensionProviders && hasActiveExtensions; + + if (useExtensions) { + // Use extension providers (includes fallback to built-in services) + _log.d('Using extension providers for download'); + _log.d( + 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', + ); + _log.d('Output dir: $outputDir'); + result = await PlatformBridge.downloadWithExtensions( + isrc: trackToDownload.isrc ?? '', + spotifyId: trackToDownload.id, + trackName: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: trackToDownload.albumName, + albumArtist: trackToDownload.albumArtist, + coverUrl: trackToDownload.coverUrl, + outputDir: outputDir, + filenameFormat: state.filenameFormat, + quality: quality, + trackNumber: trackToDownload.trackNumber ?? 1, + discNumber: trackToDownload.discNumber ?? 1, + releaseDate: trackToDownload.releaseDate, + itemId: item.id, + durationMs: trackToDownload.duration, + source: trackToDownload.source, // Pass extension ID that provided this track + ); + } else if (state.autoFallback) { _log.d('Using auto-fallback mode'); _log.d( 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', @@ -1447,6 +1596,12 @@ class DownloadQueueNotifier extends Notifier { if (result['success'] == true) { var filePath = result['file_path'] as String?; + + // Strip EXISTS: prefix from duplicate detection + if (filePath != null && filePath.startsWith('EXISTS:')) { + filePath = filePath.substring(7); // Remove "EXISTS:" prefix + } + _log.i('Download success, file: $filePath'); // Get actual quality from response (if available) @@ -1593,6 +1748,11 @@ class DownloadQueueNotifier extends Notifier { filePath: filePath, ); + // Run post-processing hooks if enabled + if (filePath != null) { + await _runPostProcessingHooks(filePath, trackToDownload); + } + // Increment completed counter _completedInSession++; diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart new file mode 100644 index 00000000..ecba3f30 --- /dev/null +++ b/lib/providers/extension_provider.dart @@ -0,0 +1,715 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; + +final _log = AppLogger('ExtensionProvider'); + +/// Represents an installed extension +class Extension { + final String id; + final String name; + final String displayName; + final String version; + final String author; + final String description; + final bool enabled; + final String status; // 'loaded', 'error', 'disabled' + final String? errorMessage; + final String? iconPath; // Path to extension icon + final List permissions; + final List settings; + final List qualityOptions; // Custom quality options for download providers + final bool hasMetadataProvider; + final bool hasDownloadProvider; + final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching + final SearchBehavior? searchBehavior; // Custom search behavior + final URLHandler? urlHandler; // Custom URL handling + final TrackMatching? trackMatching; // Custom track matching + final PostProcessing? postProcessing; // Post-processing hooks + + const Extension({ + required this.id, + required this.name, + required this.displayName, + required this.version, + required this.author, + required this.description, + required this.enabled, + required this.status, + this.errorMessage, + this.iconPath, + this.permissions = const [], + this.settings = const [], + this.qualityOptions = const [], + this.hasMetadataProvider = false, + this.hasDownloadProvider = false, + this.skipMetadataEnrichment = false, + this.searchBehavior, + this.urlHandler, + this.trackMatching, + this.postProcessing, + }); + + factory Extension.fromJson(Map json) { + return Extension( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', + version: json['version'] as String? ?? '0.0.0', + author: json['author'] as String? ?? 'Unknown', + description: json['description'] as String? ?? '', + enabled: json['enabled'] as bool? ?? false, + status: json['status'] as String? ?? 'loaded', + errorMessage: json['error_message'] as String?, + iconPath: json['icon_path'] as String?, + permissions: (json['permissions'] as List?)?.cast() ?? [], + settings: (json['settings'] as List?) + ?.map((s) => ExtensionSetting.fromJson(s as Map)) + .toList() ?? [], + qualityOptions: (json['quality_options'] as List?) + ?.map((q) => QualityOption.fromJson(q as Map)) + .toList() ?? [], + hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false, + hasDownloadProvider: json['has_download_provider'] as bool? ?? false, + skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false, + searchBehavior: json['search_behavior'] != null + ? SearchBehavior.fromJson(json['search_behavior'] as Map) + : null, + urlHandler: json['url_handler'] != null + ? URLHandler.fromJson(json['url_handler'] as Map) + : null, + trackMatching: json['track_matching'] != null + ? TrackMatching.fromJson(json['track_matching'] as Map) + : null, + postProcessing: json['post_processing'] != null + ? PostProcessing.fromJson(json['post_processing'] as Map) + : null, + ); + } + + Extension copyWith({ + String? id, + String? name, + String? displayName, + String? version, + String? author, + String? description, + bool? enabled, + String? status, + String? errorMessage, + String? iconPath, + List? permissions, + List? settings, + List? qualityOptions, + bool? hasMetadataProvider, + bool? hasDownloadProvider, + bool? skipMetadataEnrichment, + SearchBehavior? searchBehavior, + URLHandler? urlHandler, + TrackMatching? trackMatching, + PostProcessing? postProcessing, + }) { + return Extension( + id: id ?? this.id, + name: name ?? this.name, + displayName: displayName ?? this.displayName, + version: version ?? this.version, + author: author ?? this.author, + description: description ?? this.description, + enabled: enabled ?? this.enabled, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + iconPath: iconPath ?? this.iconPath, + permissions: permissions ?? this.permissions, + settings: settings ?? this.settings, + qualityOptions: qualityOptions ?? this.qualityOptions, + hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider, + hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider, + skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment, + searchBehavior: searchBehavior ?? this.searchBehavior, + urlHandler: urlHandler ?? this.urlHandler, + trackMatching: trackMatching ?? this.trackMatching, + postProcessing: postProcessing ?? this.postProcessing, + ); + } + + bool get hasCustomSearch => searchBehavior?.enabled ?? false; + bool get hasURLHandler => urlHandler?.enabled ?? false; + bool get hasCustomMatching => trackMatching?.customMatching ?? false; + bool get hasPostProcessing => postProcessing?.enabled ?? false; +} + +/// Custom search behavior configuration +class SearchBehavior { + final bool enabled; + final String? placeholder; + final bool primary; + final String? icon; + final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) + final int? thumbnailWidth; + final int? thumbnailHeight; + + const SearchBehavior({ + required this.enabled, + this.placeholder, + this.primary = false, + this.icon, + this.thumbnailRatio, + this.thumbnailWidth, + this.thumbnailHeight, + }); + + factory SearchBehavior.fromJson(Map json) { + return SearchBehavior( + enabled: json['enabled'] as bool? ?? false, + placeholder: json['placeholder'] as String?, + primary: json['primary'] as bool? ?? false, + icon: json['icon'] as String?, + thumbnailRatio: json['thumbnailRatio'] as String?, + thumbnailWidth: json['thumbnailWidth'] as int?, + thumbnailHeight: json['thumbnailHeight'] as int?, + ); + } + + /// 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); + case 'portrait': // 2:3 - Poster style + return (defaultSize * 2 / 3, defaultSize); + case 'square': // 1:1 - Album art style + default: + return (defaultSize, defaultSize); + } + } +} + +/// Custom track matching configuration +class TrackMatching { + final bool customMatching; + final String? strategy; // "isrc", "name", "duration", "custom" + final int durationTolerance; // in seconds + + const TrackMatching({ + required this.customMatching, + this.strategy, + this.durationTolerance = 3, + }); + + factory TrackMatching.fromJson(Map json) { + return TrackMatching( + customMatching: json['customMatching'] as bool? ?? false, + strategy: json['strategy'] as String?, + durationTolerance: json['durationTolerance'] as int? ?? 3, + ); + } +} + +/// Post-processing configuration +class PostProcessing { + final bool enabled; + final List hooks; + + const PostProcessing({ + required this.enabled, + this.hooks = const [], + }); + + factory PostProcessing.fromJson(Map json) { + return PostProcessing( + enabled: json['enabled'] as bool? ?? false, + hooks: (json['hooks'] as List?) + ?.map((h) => PostProcessingHook.fromJson(h as Map)) + .toList() ?? [], + ); + } +} + +/// URL handler configuration for custom URL patterns +class URLHandler { + final bool enabled; + final List patterns; + + const URLHandler({ + required this.enabled, + this.patterns = const [], + }); + + factory URLHandler.fromJson(Map json) { + return URLHandler( + enabled: json['enabled'] as bool? ?? false, + patterns: (json['patterns'] as List?)?.cast() ?? [], + ); + } + + /// Check if a URL matches any of the patterns + bool matchesURL(String url) { + if (!enabled || patterns.isEmpty) return false; + final lowerUrl = url.toLowerCase(); + for (final pattern in patterns) { + if (lowerUrl.contains(pattern.toLowerCase())) { + return true; + } + } + return false; + } +} + +/// A post-processing hook +class PostProcessingHook { + final String id; + final String name; + final String? description; + final bool defaultEnabled; + final List supportedFormats; + + const PostProcessingHook({ + required this.id, + required this.name, + this.description, + this.defaultEnabled = false, + this.supportedFormats = const [], + }); + + factory PostProcessingHook.fromJson(Map json) { + return PostProcessingHook( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + description: json['description'] as String?, + defaultEnabled: json['defaultEnabled'] as bool? ?? false, + supportedFormats: (json['supportedFormats'] as List?)?.cast() ?? [], + ); + } +} + +/// Represents a quality option for download providers +class QualityOption { + final String id; + final String label; + final String? description; + final List settings; // Quality-specific settings + + const QualityOption({ + required this.id, + required this.label, + this.description, + this.settings = const [], + }); + + factory QualityOption.fromJson(Map json) { + return QualityOption( + id: json['id'] as String? ?? '', + label: json['label'] as String? ?? '', + description: json['description'] as String?, + settings: (json['settings'] as List?) + ?.map((s) => QualitySpecificSetting.fromJson(s as Map)) + .toList() ?? [], + ); + } +} + +/// Represents a setting that's specific to a quality option +class QualitySpecificSetting { + final String key; + final String label; + final String type; // 'string', 'number', 'boolean', 'select' + final dynamic defaultValue; + final String? description; + final List? options; // For select type + final bool required; + final bool secret; + + const QualitySpecificSetting({ + required this.key, + required this.label, + required this.type, + this.defaultValue, + this.description, + this.options, + this.required = false, + this.secret = false, + }); + + factory QualitySpecificSetting.fromJson(Map json) { + return QualitySpecificSetting( + key: json['key'] as String? ?? '', + label: json['label'] as String? ?? '', + type: json['type'] as String? ?? 'string', + defaultValue: json['default'], + description: json['description'] as String?, + options: (json['options'] as List?)?.cast(), + required: json['required'] as bool? ?? false, + secret: json['secret'] as bool? ?? false, + ); + } +} + +/// Represents a setting field for an extension +class ExtensionSetting { + final String key; + final String label; + final String type; // 'string', 'number', 'boolean', 'select' + final dynamic defaultValue; + final String? description; + final List? options; // For select type + final bool required; + + const ExtensionSetting({ + required this.key, + required this.label, + required this.type, + this.defaultValue, + this.description, + this.options, + this.required = false, + }); + + factory ExtensionSetting.fromJson(Map json) { + return ExtensionSetting( + key: json['key'] as String? ?? '', + label: json['label'] as String? ?? '', + type: json['type'] as String? ?? 'string', + defaultValue: json['default'], + description: json['description'] as String?, + options: (json['options'] as List?)?.cast(), + required: json['required'] as bool? ?? false, + ); + } +} + +/// State for extension management +class ExtensionState { + final List extensions; + final List providerPriority; + final List metadataProviderPriority; + final bool isLoading; + final String? error; + final bool isInitialized; + + const ExtensionState({ + this.extensions = const [], + this.providerPriority = const [], + this.metadataProviderPriority = const [], + this.isLoading = false, + this.error, + this.isInitialized = false, + }); + + ExtensionState copyWith({ + List? extensions, + List? providerPriority, + List? metadataProviderPriority, + bool? isLoading, + String? error, + bool? isInitialized, + }) { + return ExtensionState( + extensions: extensions ?? this.extensions, + providerPriority: providerPriority ?? this.providerPriority, + metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority, + isLoading: isLoading ?? this.isLoading, + error: error, + isInitialized: isInitialized ?? this.isInitialized, + ); + } +} + + +/// Provider for managing extensions +class ExtensionNotifier extends Notifier { + @override + ExtensionState build() { + return const ExtensionState(); + } + + /// Initialize the extension system + Future initialize(String extensionsDir, String dataDir) async { + if (state.isInitialized) return; + + state = state.copyWith(isLoading: true, error: null); + + try { + await PlatformBridge.initExtensionSystem(extensionsDir, dataDir); + await loadExtensions(extensionsDir); + await loadProviderPriority(); + await loadMetadataProviderPriority(); + state = state.copyWith(isInitialized: true, isLoading: false); + _log.i('Extension system initialized'); + } catch (e) { + _log.e('Failed to initialize extension system: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Load all extensions from directory + Future loadExtensions(String dirPath) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final result = await PlatformBridge.loadExtensionsFromDir(dirPath); + _log.d('Load extensions result: $result'); + await refreshExtensions(); + state = state.copyWith(isLoading: false); + } catch (e) { + _log.e('Failed to load extensions: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Refresh the list of installed extensions + Future refreshExtensions() async { + try { + final list = await PlatformBridge.getInstalledExtensions(); + final extensions = list.map((e) => Extension.fromJson(e)).toList(); + state = state.copyWith(extensions: extensions); + _log.d('Loaded ${extensions.length} extensions'); + + // Log search behavior for extensions that have it + for (final ext in extensions) { + if (ext.searchBehavior != null) { + _log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}'); + } + } + } catch (e) { + _log.e('Failed to refresh extensions: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Clear any error state + void clearError() { + state = state.copyWith(error: null); + } + + /// Install extension from file (auto-upgrades if already installed with newer version) + Future installExtension(String filePath) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final result = await PlatformBridge.loadExtensionFromPath(filePath); + _log.i('Installed extension: ${result['name']}'); + await refreshExtensions(); + state = state.copyWith(isLoading: false); + return true; + } catch (e) { + _log.e('Failed to install extension: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + return false; + } + } + + /// Check if a package file is an upgrade for an existing extension + /// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed} + Future> checkExtensionUpgrade(String filePath) async { + try { + return await PlatformBridge.checkExtensionUpgrade(filePath); + } catch (e) { + _log.e('Failed to check extension upgrade: $e'); + return {'error': e.toString()}; + } + } + + /// Upgrade an existing extension from a new package file + Future upgradeExtension(String filePath) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final result = await PlatformBridge.upgradeExtension(filePath); + _log.i('Upgraded extension: ${result['display_name']} to v${result['version']}'); + await refreshExtensions(); + state = state.copyWith(isLoading: false); + return true; + } catch (e) { + _log.e('Failed to upgrade extension: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + return false; + } + } + + /// Uninstall/remove an extension + Future removeExtension(String extensionId) async { + state = state.copyWith(isLoading: true, error: null); + + try { + await PlatformBridge.removeExtension(extensionId); + _log.i('Removed extension: $extensionId'); + await refreshExtensions(); + state = state.copyWith(isLoading: false); + return true; + } catch (e) { + _log.e('Failed to remove extension: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + return false; + } + } + + /// Enable or disable an extension + Future setExtensionEnabled(String extensionId, bool enabled) async { + try { + 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); + } + return e; + }).toList(); + + 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'); + } + } + } catch (e) { + _log.e('Failed to set extension enabled: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Get settings for an extension + Future> getExtensionSettings(String extensionId) async { + try { + return await PlatformBridge.getExtensionSettings(extensionId); + } catch (e) { + _log.e('Failed to get extension settings: $e'); + return {}; + } + } + + /// Update settings for an extension + Future setExtensionSettings(String extensionId, Map settings) async { + try { + await PlatformBridge.setExtensionSettings(extensionId, settings); + _log.d('Updated settings for extension: $extensionId'); + } catch (e) { + _log.e('Failed to set extension settings: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Load provider priority order + Future loadProviderPriority() async { + try { + final priority = await PlatformBridge.getProviderPriority(); + state = state.copyWith(providerPriority: priority); + } catch (e) { + _log.e('Failed to load provider priority: $e'); + } + } + + /// Set provider priority order + Future setProviderPriority(List priority) async { + try { + await PlatformBridge.setProviderPriority(priority); + state = state.copyWith(providerPriority: priority); + _log.d('Updated provider priority: $priority'); + } catch (e) { + _log.e('Failed to set provider priority: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Load metadata provider priority order + Future loadMetadataProviderPriority() async { + try { + final priority = await PlatformBridge.getMetadataProviderPriority(); + state = state.copyWith(metadataProviderPriority: priority); + } catch (e) { + _log.e('Failed to load metadata provider priority: $e'); + } + } + + /// Set metadata provider priority order + Future setMetadataProviderPriority(List priority) async { + try { + await PlatformBridge.setMetadataProviderPriority(priority); + state = state.copyWith(metadataProviderPriority: priority); + _log.d('Updated metadata provider priority: $priority'); + } catch (e) { + _log.e('Failed to set metadata provider priority: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Cleanup all extensions (call on app close) + Future cleanup() async { + try { + await PlatformBridge.cleanupExtensions(); + _log.d('Extensions cleaned up'); + } catch (e) { + _log.e('Failed to cleanup extensions: $e'); + } + } + + /// Get extension by ID + Extension? getExtension(String extensionId) { + try { + return state.extensions.firstWhere((ext) => ext.id == extensionId); + } catch (_) { + return null; + } + } + + /// Get all enabled extensions + List get enabledExtensions { + return state.extensions.where((ext) => ext.enabled).toList(); + } + + /// Get all download providers (built-in + extensions) + List getAllDownloadProviders() { + final providers = ['tidal', 'qobuz', 'amazon']; + for (final ext in state.extensions) { + if (ext.enabled && ext.hasDownloadProvider) { + providers.add(ext.id); + } + } + return providers; + } + + /// Get all metadata providers (built-in + extensions) + List getAllMetadataProviders() { + final providers = ['deezer', 'spotify']; + for (final ext in state.extensions) { + if (ext.enabled && ext.hasMetadataProvider) { + providers.add(ext.id); + } + } + return providers; + } + /// Get all extensions that provide custom search + List get searchProviders { + return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); + } +} + +final extensionProvider = NotifierProvider( + ExtensionNotifier.new, +); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f162413f..ba34cc72 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -60,18 +60,16 @@ class SettingsNotifier extends Notifier { /// Apply current Spotify credentials to Go backend Future _applySpotifyCredentials() async { - // Only apply custom credentials if enabled and both fields are set - if (state.useCustomSpotifyCredentials && - state.spotifyClientId.isNotEmpty && + // Only apply if both fields are set + if (state.spotifyClientId.isNotEmpty && state.spotifyClientSecret.isNotEmpty) { await PlatformBridge.setSpotifyCredentials( state.spotifyClientId, state.spotifyClientSecret, ); - } else { - // Clear to use default - await PlatformBridge.setSpotifyCredentials('', ''); } + // Note: If credentials are empty, Spotify API will return error + // User should use Deezer as metadata source instead } void setDefaultService(String service) { @@ -197,12 +195,41 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setSearchProvider(String? 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; } + + void setUseExtensionProviders(bool enabled) { + state = state.copyWith(useExtensionProviders: enabled); + _saveSettings(); + } + + void setSeparateSingles(bool enabled) { + state = state.copyWith(separateSingles: enabled); + _saveSettings(); + } + + void setAlbumFolderStructure(String structure) { + state = state.copyWith(albumFolderStructure: structure); + _saveSettings(); + } + + void setShowExtensionStore(bool enabled) { + state = state.copyWith(showExtensionStore: enabled); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart new file mode 100644 index 00000000..eec5e7f6 --- /dev/null +++ b/lib/providers/store_provider.dart @@ -0,0 +1,286 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +final _log = AppLogger('StoreProvider'); + +/// Extension categories +class StoreCategory { + static const String metadata = 'metadata'; + static const String download = 'download'; + static const String utility = 'utility'; + static const String lyrics = 'lyrics'; + static const String integration = 'integration'; + + static const List all = [metadata, download, utility, lyrics, integration]; + + static String getDisplayName(String category) { + switch (category) { + case metadata: + return 'Metadata'; + case download: + return 'Download'; + case utility: + return 'Utility'; + case lyrics: + return 'Lyrics'; + case integration: + return 'Integration'; + default: + return category; + } + } +} + +/// Represents an extension in the store +class StoreExtension { + final String id; + final String name; + final String displayName; + final String version; + final String author; + final String description; + final String downloadUrl; + final String? iconUrl; + final String category; + final List tags; + final int downloads; + final String updatedAt; + final String? minAppVersion; + final bool isInstalled; + final String? installedVersion; + final bool hasUpdate; + + const StoreExtension({ + required this.id, + required this.name, + required this.displayName, + required this.version, + required this.author, + required this.description, + required this.downloadUrl, + this.iconUrl, + required this.category, + this.tags = const [], + this.downloads = 0, + required this.updatedAt, + this.minAppVersion, + this.isInstalled = false, + this.installedVersion, + this.hasUpdate = false, + }); + + factory StoreExtension.fromJson(Map json) { + return StoreExtension( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', + version: json['version'] as String? ?? '0.0.0', + author: json['author'] as String? ?? 'Unknown', + description: json['description'] as String? ?? '', + downloadUrl: json['download_url'] as String? ?? '', + iconUrl: json['icon_url'] as String?, + category: json['category'] as String? ?? 'utility', + tags: (json['tags'] as List?)?.cast() ?? [], + downloads: json['downloads'] as int? ?? 0, + updatedAt: json['updated_at'] as String? ?? '', + minAppVersion: json['min_app_version'] as String?, + isInstalled: json['is_installed'] as bool? ?? false, + installedVersion: json['installed_version'] as String?, + hasUpdate: json['has_update'] as bool? ?? false, + ); + } +} + +/// State for extension store +class StoreState { + final List extensions; + final String? selectedCategory; + final String searchQuery; + final bool isLoading; + final bool isDownloading; + final String? downloadingId; + final String? error; + final bool isInitialized; + + const StoreState({ + this.extensions = const [], + this.selectedCategory, + this.searchQuery = '', + this.isLoading = false, + this.isDownloading = false, + this.downloadingId, + this.error, + this.isInitialized = false, + }); + + StoreState copyWith({ + List? extensions, + String? selectedCategory, + bool clearCategory = false, + String? searchQuery, + bool? isLoading, + bool? isDownloading, + String? downloadingId, + bool clearDownloadingId = false, + String? error, + bool clearError = false, + bool? isInitialized, + }) { + return StoreState( + extensions: extensions ?? this.extensions, + selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory), + searchQuery: searchQuery ?? this.searchQuery, + isLoading: isLoading ?? this.isLoading, + isDownloading: isDownloading ?? this.isDownloading, + downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId), + error: clearError ? null : (error ?? this.error), + isInitialized: isInitialized ?? this.isInitialized, + ); + } + + /// Get filtered extensions based on category and search + List get filteredExtensions { + var result = extensions; + + if (selectedCategory != null) { + result = result.where((e) => e.category == selectedCategory).toList(); + } + + if (searchQuery.isNotEmpty) { + final query = searchQuery.toLowerCase(); + result = result.where((e) => + e.name.toLowerCase().contains(query) || + e.displayName.toLowerCase().contains(query) || + e.description.toLowerCase().contains(query) || + e.author.toLowerCase().contains(query) || + e.tags.any((t) => t.toLowerCase().contains(query)) + ).toList(); + } + + return result; + } +} + +/// Provider for managing extension store +class StoreNotifier extends Notifier { + @override + StoreState build() { + return const StoreState(); + } + + /// Initialize the store + Future initialize(String cacheDir) async { + if (state.isInitialized) return; + + state = state.copyWith(isLoading: true, clearError: true); + + try { + await PlatformBridge.initExtensionStore(cacheDir); + await refresh(); + state = state.copyWith(isInitialized: true, isLoading: false); + _log.i('Extension store initialized'); + } catch (e) { + _log.e('Failed to initialize store: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Refresh extensions from store + Future refresh({bool forceRefresh = false}) async { + state = state.copyWith(isLoading: true, clearError: true); + + try { + final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh); + state = state.copyWith( + extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(), + isLoading: false, + ); + _log.d('Loaded ${state.extensions.length} extensions from store'); + } catch (e) { + _log.e('Failed to refresh store: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Set category filter + void setCategory(String? category) { + if (category == null) { + state = state.copyWith(clearCategory: true); + } else { + state = state.copyWith(selectedCategory: category); + } + } + + /// Set search query + void setSearchQuery(String query) { + state = state.copyWith(searchQuery: query); + } + + /// Clear search + void clearSearch() { + state = state.copyWith(searchQuery: '', clearCategory: true); + } + + /// Download and install extension + Future installExtension(String extensionId, String tempDir, String extensionsDir) async { + state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); + + try { + _log.i('Downloading extension: $extensionId'); + final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); + + _log.i('Installing extension from: $downloadPath'); + final extNotifier = ref.read(extensionProvider.notifier); + final success = await extNotifier.installExtension(downloadPath); + + if (success) { + _log.i('Extension installed: $extensionId'); + await refresh(); + } + + state = state.copyWith(isDownloading: false, clearDownloadingId: true); + return success; + } catch (e) { + _log.e('Failed to install extension: $e'); + state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); + return false; + } + } + + /// Update an installed extension + Future updateExtension(String extensionId, String tempDir) async { + state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); + + try { + _log.i('Downloading update for: $extensionId'); + final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); + + _log.i('Upgrading extension from: $downloadPath'); + final extNotifier = ref.read(extensionProvider.notifier); + final success = await extNotifier.upgradeExtension(downloadPath); + + if (success) { + _log.i('Extension updated: $extensionId'); + await refresh(); + } + + state = state.copyWith(isDownloading: false, clearDownloadingId: true); + return success; + } catch (e) { + _log.e('Failed to update extension: $e'); + state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); + return false; + } + } + + /// Clear error + void clearError() { + state = state.copyWith(clearError: true); + } +} + +final storeProvider = NotifierProvider( + StoreNotifier.new, +); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index ef0ca6b5..032836f1 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; final _log = AppLogger('TrackProvider'); @@ -18,6 +20,7 @@ class TrackState { final List? artistAlbums; // For artist page final List? searchArtists; // For search results final bool hasSearchText; // For back button handling + final String? searchExtensionId; // Extension ID used for current search results const TrackState({ this.tracks = const [], @@ -32,6 +35,7 @@ class TrackState { this.artistAlbums, this.searchArtists, this.hasSearchText = false, + this.searchExtensionId, }); bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty); @@ -49,6 +53,7 @@ class TrackState { List? artistAlbums, List? searchArtists, bool? hasSearchText, + String? searchExtensionId, }) { return TrackState( tracks: tracks ?? this.tracks, @@ -63,6 +68,7 @@ class TrackState { artistAlbums: artistAlbums ?? this.artistAlbums, searchArtists: searchArtists ?? this.searchArtists, hasSearchText: hasSearchText ?? this.hasSearchText, + searchExtensionId: searchExtensionId, ); } } @@ -125,6 +131,59 @@ class TrackNotifier extends Notifier { 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'); + final result = await PlatformBridge.handleURLWithExtension(url); + if (!_isRequestValid(requestId)) return; + + if (result != null) { + final type = result['type'] as String?; + final extensionId = result['extension_id'] as String?; + + if (type == 'track' && result['track'] != null) { + final trackData = result['track'] as Map; + final track = _parseSearchTrack(trackData, source: extensionId); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + searchExtensionId: extensionId, + ); + return; + } else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) { + final trackList = result['tracks'] as List; + final tracks = trackList.map((t) => _parseSearchTrack(t as Map, source: extensionId)).toList(); + state = TrackState( + tracks: tracks, + isLoading: false, + albumId: result['album']?['id'] as String?, + albumName: result['name'] as String? ?? result['album']?['name'] as String?, + playlistName: type == 'playlist' ? result['name'] as String? : null, + coverUrl: result['cover_url'] as String?, + searchExtensionId: extensionId, + ); + return; + } else if (type == 'artist' && result['artist'] != null) { + final artistData = result['artist'] as Map; + final albumsList = artistData['albums'] as List? ?? []; + final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).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?, + artistAlbums: albums, + searchExtensionId: extensionId, + ); + return; + } + } + } + + // No extension handler found, try Spotify URL parsing final parsed = await PlatformBridge.parseSpotifyUrl(url); if (!_isRequestValid(requestId)) return; // Request cancelled @@ -210,12 +269,43 @@ class TrackNotifier extends Notifier { 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; + // Use Deezer or Spotify based on settings final source = metadataSource ?? 'deezer'; - _log.i('Search started: source=$source, query="$query"'); + _log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions'); Map results; + List 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)); + } catch (e) { + _log.e('Failed to parse extension track: $e', e); + } + } + } catch (e) { + _log.w('Extension search failed, falling back to built-in: $e'); + } + } + + // Also search with built-in providers if (source == 'deezer') { _log.d('Calling Deezer search API...'); results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5); @@ -238,11 +328,26 @@ class TrackNotifier extends Notifier { // Parse tracks with error handling per item final tracks = []; + + // 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!) + .toSet(); + for (int i = 0; i < trackList.length; i++) { final t = trackList[i]; try { if (t is Map) { - tracks.add(_parseSearchTrack(t)); + final track = _parseSearchTrack(t); + // Skip if we already have this track from extensions + if (track.isrc != null && existingIsrcs.contains(track.isrc)) { + continue; + } + tracks.add(track); } else { _log.w('Track[$i] is not a Map: ${t.runtimeType}'); } @@ -266,7 +371,7 @@ class TrackNotifier extends Notifier { } } - _log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully'); + _log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists parsed successfully'); state = TrackState( tracks: tracks, @@ -281,6 +386,53 @@ class TrackNotifier extends Notifier { } } + /// Perform custom search using a specific extension + Future customSearch(String extensionId, String query, {Map? 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 { + _log.i('Custom search started: extension=$extensionId, query="$query"'); + + final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options); + + if (!_isRequestValid(requestId)) { + _log.w('Custom search request cancelled (requestId=$requestId)'); + return; + } + + _log.i('Custom search returned ${results.length} tracks'); + + // Parse tracks with error handling per item, setting source to extension ID + final tracks = []; + for (int i = 0; i < results.length; i++) { + final t = results[i]; + try { + tracks.add(_parseSearchTrack(t, source: extensionId)); + } catch (e) { + _log.e('Failed to parse custom search track[$i]: $e', e); + } + } + + _log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)'); + + state = TrackState( + tracks: tracks, + searchArtists: [], // Custom search doesn't return artists + isLoading: false, + hasSearchText: state.hasSearchText, + searchExtensionId: extensionId, // Store which extension was used + ); + } catch (e, stackTrace) { + if (!_isRequestValid(requestId)) return; + _log.e('Custom search failed: $e', e, stackTrace); + state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText); + } + } + Future checkAvailability(int index) async { if (index < 0 || index >= state.tracks.length) return; @@ -344,7 +496,7 @@ class TrackNotifier extends Notifier { ); } - Track _parseSearchTrack(Map data) { + Track _parseSearchTrack(Map data, {String? source}) { // Handle duration_ms which might be int or double int durationMs = 0; final durationValue = data['duration_ms']; @@ -366,6 +518,8 @@ class TrackNotifier extends Notifier { trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date']?.toString(), + source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(), + albumType: data['album_type']?.toString(), ); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 87fc3fca..64eb8fa6 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -7,6 +7,7 @@ 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/services/platform_bridge.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; /// Simple in-memory cache for album tracks class _AlbumCache { @@ -316,10 +317,16 @@ class _AlbumScreenState extends ConsumerState { void _downloadTrack(BuildContext context, Track track) { final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality, service) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); - }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + 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'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); @@ -331,84 +338,21 @@ class _AlbumScreenState extends ConsumerState { if (tracks == null || tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality, service) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); - }, trackName: '${tracks.length} tracks', artistName: widget.albumName); + DownloadServicePicker.show( + context, + trackName: '${tracks.length} tracks', + 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'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); } } - void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) { - final colorScheme = Theme.of(context).colorScheme; - final settings = ref.read(settingsProvider); - String selectedService = settings.defaultService; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), - isScrollControlled: true, - builder: (context) => StatefulBuilder( - builder: (context, setModalState) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (trackName != null) ...[ - _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ] else ...[ - const SizedBox(height: 8), - Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), - ], - // Service selector - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - _ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')), - const SizedBox(width: 8), - _ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')), - const SizedBox(width: 8), - _ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - // Disclaimer - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }), - _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }), - _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ); - } - /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || @@ -473,148 +417,6 @@ class _AlbumScreenState extends ConsumerState { } } -class _QualityOption extends StatelessWidget { - final String title; - final String subtitle; - final IconData icon; - final VoidCallback onTap; - - const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), - onTap: onTap, - ); - } -} - -class _ServiceChip extends StatelessWidget { - final String label; - final bool isSelected; - final VoidCallback onTap; - const _ServiceChip({required this.label, required this.isSelected, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ); - } -} - -class _TrackInfoHeader extends StatefulWidget { - final String trackName; - final String? artistName; - final String? coverUrl; - const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl}); - - @override - State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); -} - -class _TrackInfoHeaderState extends State<_TrackInfoHeader> { - bool _expanded = false; - bool _isOverflowing = false; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, - borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)), - child: Column( - children: [ - const SizedBox(height: 8), - Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.coverUrl != null - ? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, - errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))) - : Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), - ), - const SizedBox(width: 12), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); - final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); - final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth); - final titleOverflows = titlePainter.didExceedMaxLines; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _isOverflowing != titleOverflows) { - setState(() => _isOverflowing = titleOverflows); - } - }); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.trackName, - style: titleStyle, - maxLines: _expanded ? 10 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - if (widget.artistName != null) ...[ - const SizedBox(height: 2), - Text( - widget.artistName!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: _expanded ? 3 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - ], - ], - ); - }, - ), - ), - if (_isOverflowing || _expanded) - Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20), - ], - ), - ), - ], - ), - ), - ); - } -} - /// Separate Consumer widget for each track - only rebuilds when this specific track's status changes class _AlbumTrackItem extends ConsumerWidget { final Track track; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index e7fa901b..846d583e 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -8,12 +8,14 @@ import 'package:spotiflac_android/models/track.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/providers/extension_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -78,12 +80,31 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Future _performSearch(String query) async { - // Skip if same query already searched - if (_lastSearchQuery == query) return; - _lastSearchQuery = query; - final settings = ref.read(settingsProvider); - await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource); + final extState = ref.read(extensionProvider); + final searchProvider = settings.searchProvider; + + // Skip if same query already searched with same provider + final searchKey = '${searchProvider ?? 'default'}:$query'; + if (_lastSearchQuery == searchKey) return; + _lastSearchQuery = searchKey; + + // Check if extension search provider is set AND still enabled + final isExtensionEnabled = searchProvider != null && + searchProvider.isNotEmpty && + extState.extensions.any((e) => e.id == searchProvider && e.enabled); + + if (isExtensionEnabled) { + // Use custom search from extension + await ref.read(trackProvider.notifier).customSearch(searchProvider, query); + } else { + // Use default search (Deezer/Spotify) + // Also clear searchProvider if it was set but extension is disabled + if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + } + await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource); + } ref.read(settingsProvider.notifier).setHasSearchedBefore(); } @@ -173,10 +194,16 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality, service) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); - }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + 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'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); @@ -184,107 +211,23 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) { - final colorScheme = Theme.of(context).colorScheme; - final settings = ref.read(settingsProvider); - String selectedService = settings.defaultService; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), - isScrollControlled: true, - builder: (context) => StatefulBuilder( - builder: (context, setModalState) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (trackName != null) ...[ - _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ] else ...[ - const SizedBox(height: 8), - Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), - ], - // Service selector - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - _ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')), - const SizedBox(width: 8), - _ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')), - const SizedBox(width: 8), - _ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - // Disclaimer - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - _QualityPickerOption( - title: 'FLAC Lossless', - subtitle: '16-bit / 44.1kHz', - icon: Icons.music_note, - onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }, - ), - _QualityPickerOption( - title: 'Hi-Res FLAC', - subtitle: '24-bit / up to 96kHz', - icon: Icons.high_quality, - onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }, - ), - _QualityPickerOption( - title: 'Hi-Res FLAC Max', - subtitle: '24-bit / up to 192kHz', - icon: Icons.four_k, - onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }, - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ); - } - Future _importCsv(BuildContext context, WidgetRef ref) async { // Show loading dialog with progress int currentProgress = 0; int totalTracks = 0; // Use StatefulBuilder to update dialog content - final dialogContext = context; bool dialogShown = false; StateSetter? setDialogState; void showProgressDialog() { - if (dialogShown) return; + if (dialogShown || !mounted) return; dialogShown = true; showDialog( - context: dialogContext, + context: this.context, barrierDismissible: false, - builder: (context) => StatefulBuilder( - builder: (context, setState) { + builder: (dialogCtx) => StatefulBuilder( + builder: (dialogCtx, setState) { setDialogState = setState; return AlertDialog( content: Column( @@ -318,25 +261,27 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // Close progress dialog if (dialogShown && mounted) { - Navigator.of(dialogContext).pop(); + Navigator.of(this.context).pop(); } if (tracks.isNotEmpty) { final settings = ref.read(settingsProvider); + if (!mounted) return; + // Optionally show confirmation dialog final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( + context: this.context, + builder: (dialogCtx) => AlertDialog( title: const Text('Import Playlist'), content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'), actions: [ TextButton( - onPressed: () => Navigator.pop(context, false), + onPressed: () => Navigator.pop(dialogCtx, false), child: const Text('Cancel'), ), FilledButton( - onPressed: () => Navigator.pop(context, true), + onPressed: () => Navigator.pop(dialogCtx, true), child: const Text('Import'), ), ], @@ -346,7 +291,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (confirmed == true) { ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(this.context).showSnackBar( SnackBar( content: Text('Added ${tracks.length} tracks to queue'), action: SnackBarAction( @@ -385,6 +330,10 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final error = ref.watch(trackProvider.select((s) => s.error)); final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore)); + // Watch extension state to update search hint when extensions load/change + ref.watch(extensionProvider.select((s) => s.isInitialized)); + ref.watch(extensionProvider.select((s) => s.extensions)); + final colorScheme = Theme.of(context).colorScheme; final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading; final screenHeight = MediaQuery.of(context).size.height; @@ -836,6 +785,32 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } + /// Get search hint based on selected provider + String _getSearchHint() { + final settings = ref.read(settingsProvider); + final searchProvider = settings.searchProvider; + final extState = ref.read(extensionProvider); + + // If extension system not initialized yet, show default hint + if (!extState.isInitialized) { + return 'Paste Spotify URL or search...'; + } + + if (searchProvider != null && searchProvider.isNotEmpty) { + final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull; + // Only show extension placeholder if extension exists AND is enabled + if (ext != null && ext.enabled) { + if (ext.searchBehavior?.placeholder != null) { + return ext.searchBehavior!.placeholder!; + } + return 'Search with ${ext.displayName}...'; + } + // Extension not found or disabled - clear the search provider setting + // and return default hint + } + return 'Paste Spotify URL or search...'; + } + Widget _buildSearchBar(ColorScheme colorScheme) { final hasText = _urlController.text.isNotEmpty; @@ -844,7 +819,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient focusNode: _searchFocusNode, autofocus: false, decoration: InputDecoration( - hintText: 'Paste Spotify URL or search...', + hintText: _getSearchHint(), filled: true, fillColor: colorScheme.surfaceContainerHighest, border: OutlineInputBorder( @@ -910,147 +885,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } -class _QualityPickerOption extends StatelessWidget { - final String title; - final String subtitle; - final IconData icon; - final VoidCallback onTap; - const _QualityPickerOption({required this.title, required this.subtitle, required this.icon, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), - onTap: onTap, - ); - } -} - -class _ServiceChip extends StatelessWidget { - final String label; - final bool isSelected; - final VoidCallback onTap; - const _ServiceChip({required this.label, required this.isSelected, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ); - } -} - -class _TrackInfoHeader extends StatefulWidget { - final String trackName; - final String? artistName; - final String? coverUrl; - const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl}); - - @override - State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); -} - -class _TrackInfoHeaderState extends State<_TrackInfoHeader> { - bool _expanded = false; - bool _isOverflowing = false; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, - borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)), - child: Column( - children: [ - const SizedBox(height: 8), - Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.coverUrl != null - ? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, - errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))) - : Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), - ), - const SizedBox(width: 12), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); - final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); - final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth); - final titleOverflows = titlePainter.didExceedMaxLines; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _isOverflowing != titleOverflows) { - setState(() => _isOverflowing = titleOverflows); - } - }); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.trackName, - style: titleStyle, - maxLines: _expanded ? 10 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - if (widget.artistName != null) ...[ - const SizedBox(height: 2), - Text( - widget.artistName!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: _expanded ? 3 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - ], - ], - ); - }, - ), - ), - if (_isOverflowing || _expanded) - Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20), - ], - ), - ), - ], - ), - ), - ); - } -} - /// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes class _TrackItemWithStatus extends ConsumerWidget { final Track track; @@ -1080,6 +914,28 @@ class _TrackItemWithStatus extends ConsumerWidget { return state.isDownloaded(track.id); })); + // Get thumbnail size from extension if track is from extension + double thumbWidth = 56; + double thumbHeight = 56; + + // Get extension ID from track.source or from TrackState.searchExtensionId + final trackState = ref.watch(trackProvider); + final extensionId = track.source ?? trackState.searchExtensionId; + + if (extensionId != null && extensionId.isNotEmpty) { + final extState = ref.watch(extensionProvider); + final extension = extState.extensions.where((e) => e.id == extensionId).firstOrNull; + if (extension?.searchBehavior != null) { + final size = extension!.searchBehavior!.getThumbnailSize(defaultSize: 56); + thumbWidth = size.$1; + thumbHeight = size.$2; + // Debug: log only when using custom size + if (thumbWidth != 56 || thumbHeight != 56) { + debugPrint('[Thumbnail] ${track.name}: using ${thumbWidth.toInt()}x${thumbHeight.toInt()} from ${extension.id}'); + } + } + } + final isQueued = queueItem != null; final isDownloading = queueItem?.status == DownloadStatus.downloading; final isFinalizing = queueItem?.status == DownloadStatus.finalizing; @@ -1100,21 +956,21 @@ class _TrackItemWithStatus extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Album art + // Album art with dynamic size based on extension config ClipRRect( borderRadius: BorderRadius.circular(10), child: track.coverUrl != null ? CachedNetworkImage( imageUrl: track.coverUrl!, - width: 56, - height: 56, + width: thumbWidth, + height: thumbHeight, fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, + memCacheWidth: (thumbWidth * 2).toInt(), + memCacheHeight: (thumbHeight * 2).toInt(), ) : Container( - width: 56, - height: 56, + width: thumbWidth, + height: thumbHeight, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), ), @@ -1151,7 +1007,7 @@ class _TrackItemWithStatus extends ConsumerWidget { Divider( height: 1, thickness: 1, - indent: 80, + indent: thumbWidth + 24, // Adjust divider indent based on thumbnail width endIndent: 12, color: colorScheme.outlineVariant.withValues(alpha: 0.3), ), diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 92b016c0..9b7932ed 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_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'; import 'package:spotiflac_android/screens/queue_tab.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; @@ -121,6 +122,8 @@ class _MainShellState extends ConsumerState { void _onPageChanged(int index) { if (_currentIndex != index) { setState(() => _currentIndex = index); + // Unfocus any text field when switching tabs to prevent keyboard from appearing + FocusScope.of(context).unfocus(); } } @@ -172,6 +175,7 @@ class _MainShellState extends ConsumerState { Widget build(BuildContext context) { final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final trackState = ref.watch(trackProvider); + final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore)); // Check if keyboard is visible (bottom inset > 0 means keyboard is showing) final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; @@ -185,6 +189,61 @@ class _MainShellState extends ConsumerState { !trackState.isLoading && !isKeyboardVisible; + // Build tabs and destinations based on settings + final tabs = [ + const HomeTab(), + QueueTab( + parentPageController: _pageController, + parentPageIndex: 1, + nextPageIndex: showStore ? 2 : 3, + ), + if (showStore) const StoreTab(), + const SettingsTab(), + ]; + + final destinations = [ + const NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.history_outlined), + ), + selectedIcon: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.history), + ), + label: 'History', + ), + if (showStore) + const NavigationDestination( + icon: Icon(Icons.store_outlined), + selectedIcon: Icon(Icons.store), + label: 'Store', + ), + const NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ]; + + // Clamp current index if tabs changed + final maxIndex = tabs.length - 1; + if (_currentIndex > maxIndex) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _currentIndex = maxIndex); + _pageController.jumpToPage(maxIndex); + } + }); + } + return PopScope( canPop: canPop, onPopInvokedWithResult: (didPop, result) async { @@ -201,45 +260,17 @@ class _MainShellState extends ConsumerState { body: PageView( controller: _pageController, onPageChanged: _onPageChanged, - physics: const BouncingScrollPhysics(), - children: const [ - HomeTab(), - QueueTab(), - SettingsTab(), - ], + physics: const ClampingScrollPhysics(), + children: tabs, ), bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, + selectedIndex: _currentIndex.clamp(0, maxIndex), onDestinationSelected: _onNavTap, animationDuration: const Duration(milliseconds: 200), backgroundColor: Theme.of(context).brightness == Brightness.dark ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface) : Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface), - destinations: [ - const NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', - ), - NavigationDestination( - icon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.history_outlined), - ), - selectedIcon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.history), - ), - label: 'History', - ), - const NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', - ), - ], + destinations: destinations, ), ), ); diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index cc3baec7..73d9c962 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -6,6 +6,7 @@ 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/widgets/download_service_picker.dart'; /// Playlist detail screen with Material Expressive 3 design class PlaylistScreen extends ConsumerWidget { @@ -168,10 +169,16 @@ class PlaylistScreen extends ConsumerWidget { void _downloadTrack(BuildContext context, WidgetRef ref, Track track) { final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, ref, (quality, service) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); - }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + 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'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); @@ -182,222 +189,20 @@ class PlaylistScreen extends ConsumerWidget { if (tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, ref, (quality, service) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); - }, trackName: '${tracks.length} tracks', artistName: playlistName); + DownloadServicePicker.show( + context, + trackName: '${tracks.length} tracks', + 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'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); } } - - void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) { - final colorScheme = Theme.of(context).colorScheme; - final settings = ref.read(settingsProvider); - String selectedService = settings.defaultService; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), - isScrollControlled: true, - builder: (context) => StatefulBuilder( - builder: (context, setModalState) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (trackName != null) ...[ - _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ] else ...[ - const SizedBox(height: 8), - Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), - ], - // Service selector - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - _ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')), - const SizedBox(width: 8), - _ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')), - const SizedBox(width: 8), - _ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')), - ], - ), - ), - Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))), - // Disclaimer - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }), - _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }), - _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ); - } -} - -class _QualityOption extends StatelessWidget { - final String title; - final String subtitle; - final IconData icon; - final VoidCallback onTap; - - const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), - onTap: onTap, - ); - } -} - -class _ServiceChip extends StatelessWidget { - final String label; - final bool isSelected; - final VoidCallback onTap; - const _ServiceChip({required this.label, required this.isSelected, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ); - } -} - -class _TrackInfoHeader extends StatefulWidget { - final String trackName; - final String? artistName; - final String? coverUrl; - const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl}); - - @override - State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); -} - -class _TrackInfoHeaderState extends State<_TrackInfoHeader> { - bool _expanded = false; - bool _isOverflowing = false; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, - borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)), - child: Column( - children: [ - const SizedBox(height: 8), - Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.coverUrl != null - ? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, - errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))) - : Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), - ), - const SizedBox(width: 12), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); - final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); - final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth); - final titleOverflows = titlePainter.didExceedMaxLines; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _isOverflowing != titleOverflows) { - setState(() => _isOverflowing = titleOverflows); - } - }); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.trackName, - style: titleStyle, - maxLines: _expanded ? 10 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - if (widget.artistName != null) ...[ - const SizedBox(height: 2), - Text( - widget.artistName!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: _expanded ? 3 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - ], - ], - ); - }, - ), - ), - if (_isOverflowing || _expanded) - Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20), - ], - ), - ), - ], - ), - ), - ); - } } /// Separate Consumer widget for each track - only rebuilds when this specific track's status changes diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index fca21484..5dc347ed 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -30,7 +30,17 @@ class _GroupedAlbum { } class QueueTab extends ConsumerStatefulWidget { - const QueueTab({super.key}); + final PageController? parentPageController; + final int parentPageIndex; + final int? nextPageIndex; + + const QueueTab({ + super.key, + this.parentPageController, + this.parentPageIndex = 1, + this.nextPageIndex, + }); + @override ConsumerState createState() => _QueueTabState(); } @@ -44,6 +54,46 @@ class _QueueTabState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedIds = {}; + // Filter page controller for swipe between All/Albums/Singles + PageController? _filterPageController; + final List _filterModes = ['all', 'albums', 'singles']; + bool _isPageControllerInitialized = false; + + + + @override + void initState() { + super.initState(); + // Will be initialized in build when we have access to ref + } + + void _initializePageController() { + if (_isPageControllerInitialized) return; + _isPageControllerInitialized = true; + final currentFilter = ref.read(settingsProvider).historyFilterMode; + final initialPage = _filterModes.indexOf(currentFilter).clamp(0, 2); + _filterPageController = PageController(initialPage: initialPage); + } + + @override + void dispose() { + _filterPageController?.dispose(); + super.dispose(); + } + + void _onFilterPageChanged(int index) { + final filterMode = _filterModes[index]; + ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); + } + + void _animateToFilterPage(int index) { + _filterPageController?.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + /// Enter selection mode with initial item void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); @@ -89,7 +139,9 @@ class _QueueTabState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: const Text('Delete Selected'), - content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from history?\n\nThis will also delete the files from storage.'), + content: Text( + 'Delete $count ${count == 1 ? 'track' : 'tracks'} from history?\n\nThis will also delete the files from storage.', + ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), @@ -109,13 +161,14 @@ class _QueueTabState extends ConsumerState { if (confirmed == true && mounted) { final historyNotifier = ref.read(downloadHistoryProvider.notifier); final items = ref.read(downloadHistoryProvider).items; - + int deletedCount = 0; for (final id in _selectedIds) { final item = items.where((e) => e.id == id).firstOrNull; if (item != null) { try { - final file = File(item.filePath); + final cleanPath = _cleanFilePath(item.filePath); + final file = File(cleanPath); if (await file.exists()) { await file.delete(); } @@ -124,107 +177,164 @@ class _QueueTabState extends ConsumerState { deletedCount++; } } - + _exitSelectionMode(); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')), + SnackBar( + content: Text( + 'Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}', + ), + ), ); } } } + /// Strip EXISTS: prefix from file path (legacy history items) + String _cleanFilePath(String? filePath) { + if (filePath == null) return ''; + if (filePath.startsWith('EXISTS:')) { + return filePath.substring(7); + } + return filePath; + } + bool _checkFileExists(String? filePath) { if (filePath == null) return false; - if (_fileExistsCache.containsKey(filePath)) { - return _fileExistsCache[filePath]!; + final cleanPath = _cleanFilePath(filePath); + if (cleanPath.isEmpty) return false; + if (_fileExistsCache.containsKey(cleanPath)) { + return _fileExistsCache[cleanPath]!; } - if (_pendingChecks.contains(filePath)) { + if (_pendingChecks.contains(cleanPath)) { return true; } if (_fileExistsCache.length >= _maxCacheSize) { _fileExistsCache.remove(_fileExistsCache.keys.first); } - _pendingChecks.add(filePath); + _pendingChecks.add(cleanPath); Future.microtask(() async { - final exists = await File(filePath).exists(); - _pendingChecks.remove(filePath); - if (mounted && _fileExistsCache[filePath] != exists) { - setState(() => _fileExistsCache[filePath] = exists); + final exists = await File(cleanPath).exists(); + _pendingChecks.remove(cleanPath); + if (mounted && _fileExistsCache[cleanPath] != exists) { + setState(() => _fileExistsCache[cleanPath] = exists); } }); return true; } Future _openFile(String filePath) async { + final cleanPath = _cleanFilePath(filePath); try { - await OpenFilex.open(filePath); + // Determine MIME type based on file extension + final extension = cleanPath.split('.').last.toLowerCase(); + String mimeType; + switch (extension) { + case 'flac': + mimeType = 'audio/flac'; + break; + case 'mp3': + mimeType = 'audio/mpeg'; + break; + case 'wav': + mimeType = 'audio/wav'; + break; + case 'm4a': + case 'aac': + mimeType = 'audio/mp4'; + break; + case 'ogg': + mimeType = 'audio/ogg'; + break; + default: + mimeType = 'audio/*'; + } + await OpenFilex.open(cleanPath, type: mimeType); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Cannot open file: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Cannot open file: $e'))); } } } void _navigateToMetadataScreen(DownloadItem item) { - final historyItem = ref.read(downloadHistoryProvider).items.firstWhere( - (h) => h.filePath == item.filePath, - orElse: () => DownloadHistoryItem( - id: item.id, - trackName: item.track.name, - artistName: item.track.artistName, - albumName: item.track.albumName, - coverUrl: item.track.coverUrl, - filePath: item.filePath ?? '', - downloadedAt: DateTime.now(), - service: item.service, + final historyItem = ref + .read(downloadHistoryProvider) + .items + .firstWhere( + (h) => h.filePath == item.filePath, + orElse: () => DownloadHistoryItem( + id: item.id, + trackName: item.track.name, + artistName: item.track.artistName, + albumName: item.track.albumName, + coverUrl: item.track.coverUrl, + filePath: item.filePath ?? '', + downloadedAt: DateTime.now(), + service: item.service, + ), + ); + + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: historyItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), ), ); - - Navigator.push(context, PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), - )); } void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { - Navigator.push(context, PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), - )); + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: item), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); } /// Filter history items based on current filter mode /// Album = track yang albumnya punya >1 track di history /// Single = track yang albumnya cuma 1 track di history - List _filterHistoryItems(List items, String filterMode) { + List _filterHistoryItems( + List items, + String filterMode, + ) { if (filterMode == 'all') return items; - + // Count tracks per album final albumCounts = {}; for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumCounts[key] = (albumCounts[key] ?? 0) + 1; } - + switch (filterMode) { case 'albums': // Album = more than 1 track from same album in history return items.where((item) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + final key = + '${item.albumName}|${item.albumArtist ?? item.artistName}'; return (albumCounts[key] ?? 0) > 1; }).toList(); case 'singles': // Single = only 1 track from that album in history return items.where((item) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + final key = + '${item.albumName}|${item.albumArtist ?? item.artistName}'; return (albumCounts[key] ?? 0) == 1; }).toList(); default: @@ -240,10 +350,10 @@ class _QueueTabState extends ConsumerState { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumCounts[key] = (albumCounts[key] ?? 0) + 1; } - + int albumTracks = 0; int singleTracks = 0; - + for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; if ((albumCounts[key] ?? 0) > 1) { @@ -252,44 +362,45 @@ class _QueueTabState extends ConsumerState { singleTracks++; } } - + return {'albums': albumTracks, 'singles': singleTracks}; } /// Group history items by album (for Albums filter view) List<_GroupedAlbum> _groupByAlbum(List items) { final albumMap = >{}; - + for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumMap.putIfAbsent(key, () => []).add(item); } - + // Only include albums with more than 1 track - final groupedAlbums = albumMap.entries - .where((e) => e.value.length > 1) - .map((e) { - final tracks = e.value; - // Sort tracks by track number - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - return _GroupedAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverUrl: tracks.first.coverUrl, - tracks: tracks, - latestDownload: tracks.map((t) => t.downloadedAt).reduce((a, b) => a.isAfter(b) ? a : b), - ); - }) - .toList(); - + final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map( + (e) { + final tracks = e.value; + // Sort tracks by track number + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); + + return _GroupedAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverUrl: tracks.first.coverUrl, + tracks: tracks, + latestDownload: tracks + .map((t) => t.downloadedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + ); + }, + ).toList(); + // Sort by latest download groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); - + return groupedAlbums; } @@ -300,51 +411,72 @@ class _QueueTabState extends ConsumerState { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumKeys.add(key); } - + // Count albums with more than 1 track int count = 0; for (final key in albumKeys) { - final trackCount = items.where((i) => '${i.albumName}|${i.albumArtist ?? i.artistName}' == key).length; + final trackCount = items + .where( + (i) => '${i.albumName}|${i.albumArtist ?? i.artistName}' == key, + ) + .length; if (trackCount > 1) count++; } return count; } void _navigateToDownloadedAlbum(_GroupedAlbum album) { - Navigator.push(context, PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => DownloadedAlbumScreen( - albumName: album.albumName, - artistName: album.artistName, - coverUrl: album.coverUrl, + Navigator.push( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + DownloadedAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverUrl: album.coverUrl, + ), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), ), - transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), - )); + ); } @override Widget build(BuildContext context) { + // Initialize page controller on first build + _initializePageController(); + final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); - final isProcessing = ref.watch(downloadQueueProvider.select((s) => s.isProcessing)); + final isProcessing = ref.watch( + downloadQueueProvider.select((s) => s.isProcessing), + ); final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused)); - final queuedCount = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); - final completedCount = ref.watch(downloadQueueProvider.select((s) => s.completedCount)); - final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); - final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode)); - final historyFilterMode = ref.watch(settingsProvider.select((s) => s.historyFilterMode)); + final queuedCount = ref.watch( + downloadQueueProvider.select((s) => s.queuedCount), + ); + final completedCount = ref.watch( + downloadQueueProvider.select((s) => s.completedCount), + ); + final allHistoryItems = ref.watch( + downloadHistoryProvider.select((s) => s.items), + ); + final historyViewMode = ref.watch( + settingsProvider.select((s) => s.historyViewMode), + ); + final historyFilterMode = ref.watch( + settingsProvider.select((s) => s.historyFilterMode), + ); final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - // Filter history items - final historyItems = _filterHistoryItems(allHistoryItems, historyFilterMode); - // Group albums for Albums filter view final groupedAlbums = _groupByAlbum(allHistoryItems); - + // Count for filter chips final counts = _countAlbumsAndSingles(allHistoryItems); - final albumCount = _countUniqueAlbums(allHistoryItems); // Show unique album count + final albumCount = _countUniqueAlbums(allHistoryItems); final singleCount = counts['singles'] ?? 0; final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -358,8 +490,8 @@ class _QueueTabState extends ConsumerState { }, child: Stack( children: [ - CustomScrollView( - slivers: [ + NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ // App Bar - always normal style SliverAppBar( expandedHeight: 120 + topPadding, @@ -373,8 +505,11 @@ class _QueueTabState extends ConsumerState { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); - + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + return FlexibleSpaceBar( expandedTitleScale: 1.0, titlePadding: const EdgeInsets.only(left: 24, bottom: 16), @@ -391,234 +526,420 @@ class _QueueTabState extends ConsumerState { ), ), - // Pause/Resume controls - if ((isProcessing || queuedCount > 0) && (queueItems.length > 1 || isPaused)) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Card( + // Pause/Resume controls + if ((isProcessing || queuedCount > 0) && + (queueItems.length > 1 || isPaused)) + SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: isPaused ? colorScheme.errorContainer : colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - isPaused ? Icons.pause : Icons.downloading, - color: isPaused ? colorScheme.onErrorContainer : colorScheme.onPrimaryContainer, - ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isPaused + ? colorScheme.errorContainer + : colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isPaused ? Icons.pause : Icons.downloading, + color: isPaused + ? colorScheme.onErrorContainer + : colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + isPaused + ? 'Paused' + : '$completedCount/${queueItems.length}', + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + FilledButton.tonal( + onPressed: () => ref + .read(downloadQueueProvider.notifier) + .togglePause(), + child: Text(isPaused ? 'Resume' : 'Pause'), + ), + ], ), - const SizedBox(width: 12), - Expanded( - child: Text( - isPaused ? 'Paused' : '$completedCount/${queueItems.length}', - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), - ), - ), - FilledButton.tonal( - onPressed: () => ref.read(downloadQueueProvider.notifier).togglePause(), - child: Text(isPaused ? 'Resume' : 'Pause'), - ), - ], + ), ), ), ), - ), - ), - // Queue header - if (queueItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text('Downloading (${queueItems.length})', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - ), - - // Queue list - if (queueItems.isNotEmpty) - SliverList(delegate: SliverChildBuilderDelegate( - (context, index) { - final item = queueItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), - child: _buildQueueItem(context, item, colorScheme), - ); - }, - childCount: queueItems.length, - )), - - // Filter chips (only show when history has items) - if (allHistoryItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _FilterChip( - label: 'All', - count: allHistoryItems.length, - isSelected: historyFilterMode == 'all', - onTap: () => ref.read(settingsProvider.notifier).setHistoryFilterMode('all'), + // Queue header + if (queueItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'Downloading (${queueItems.length})', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), - const SizedBox(width: 8), - _FilterChip( - label: 'Albums', - count: albumCount, - isSelected: historyFilterMode == 'albums', - onTap: () => ref.read(settingsProvider.notifier).setHistoryFilterMode('albums'), - ), - const SizedBox(width: 8), - _FilterChip( - label: 'Singles', - count: singleCount, - isSelected: historyFilterMode == 'singles', - onTap: () => ref.read(settingsProvider.notifier).setHistoryFilterMode('singles'), - ), - ], + ), ), ), - ), - ), - // History section header - if (historyItems.isNotEmpty && queueItems.isEmpty && historyFilterMode != 'albums') - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Row( - children: [ - Text('${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const Spacer(), - if (!_isSelectionMode) - TextButton.icon( - onPressed: historyItems.isNotEmpty ? () => _enterSelectionMode(historyItems.first.id) : null, - icon: const Icon(Icons.checklist, size: 18), - label: const Text('Select'), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ), - ), - - // Albums section header (when Albums filter is selected) - if (groupedAlbums.isNotEmpty && queueItems.isEmpty && historyFilterMode == 'albums') - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text('${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ), - ), - - // History section header when queue has items - if (historyItems.isNotEmpty && queueItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Downloaded', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - ), - - // Albums Grid (when Albums filter is selected) - if (historyFilterMode == 'albums' && groupedAlbums.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.75, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final album = groupedAlbums[index]; + // Queue list + if (queueItems.isNotEmpty) + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = queueItems[index]; return KeyedSubtree( - key: ValueKey(album.key), - child: _buildAlbumGridItem(context, album, colorScheme), + key: ValueKey(item.id), + child: _buildQueueItem(context, item, colorScheme), ); - }, - childCount: groupedAlbums.length, + }, childCount: queueItems.length), + ), + + // Filter chips (only show when history has items) + if (allHistoryItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'All', + count: allHistoryItems.length, + isSelected: historyFilterMode == 'all', + onTap: () { + _animateToFilterPage(0); + }, + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Albums', + count: albumCount, + isSelected: historyFilterMode == 'albums', + onTap: () { + _animateToFilterPage(1); + }, + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Singles', + count: singleCount, + isSelected: historyFilterMode == 'singles', + onTap: () { + _animateToFilterPage(2); + }, + ), + ], + ), + ), + ), + ), + ], + body: NotificationListener( + onNotification: (notification) { + final parentController = widget.parentPageController; + if (parentController == null || !parentController.hasClients) { + return false; + } + + final page = _filterPageController!.page?.round() ?? 0; + + if (notification is OverscrollNotification) { + final overscroll = notification.overscroll; + + // At first page and overscrolling to the left -> push parent toward Home + if (page == 0 && overscroll < 0) { + final currentOffset = parentController.offset; + final targetOffset = (currentOffset + overscroll).clamp( + 0.0, + parentController.position.maxScrollExtent, + ); + parentController.jumpTo(targetOffset); + return true; + } + + // At last page and overscrolling to the right -> push parent toward next tab + if (page == 2 && overscroll > 0) { + final currentOffset = parentController.offset; + final targetOffset = (currentOffset + overscroll).clamp( + 0.0, + parentController.position.maxScrollExtent, + ); + parentController.jumpTo(targetOffset); + return true; + } + } + + // Snap parent to nearest page when scroll ends + if (notification is ScrollEndNotification) { + if (page == 0 || page == 2) { + final currentPage = parentController.page ?? widget.parentPageIndex.toDouble(); + final historyPage = widget.parentPageIndex.toDouble(); + final offset = currentPage - historyPage; + + // Only snap if we've moved the parent + if (offset.abs() > 0.01) { + // Use 0.3 threshold (30%) + if (offset < -0.3) { + // Swiped enough toward Home - animate to Home + parentController.animateToPage( + widget.parentPageIndex - 1, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } else if (offset > 0.3) { + // Swiped enough toward next tab - animate to next + parentController.animateToPage( + widget.nextPageIndex ?? (widget.parentPageIndex + 1), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } else { + // Not enough - instant jump back (no animation) + parentController.jumpToPage(widget.parentPageIndex); + } + } + } + } + + return false; + }, + child: PageView( + controller: _filterPageController!, + physics: const ClampingScrollPhysics(), + onPageChanged: _onFilterPageChanged, + children: [ + // All tab + _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: 'all', + allHistoryItems: allHistoryItems, + historyViewMode: historyViewMode, + queueItems: queueItems, + groupedAlbums: groupedAlbums, + ), + // Albums tab + _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: 'albums', + allHistoryItems: allHistoryItems, + historyViewMode: historyViewMode, + queueItems: queueItems, + groupedAlbums: groupedAlbums, + ), + // Singles tab + _buildFilterContent( + context: context, + colorScheme: colorScheme, + filterMode: 'singles', + allHistoryItems: allHistoryItems, + historyViewMode: historyViewMode, + queueItems: queueItems, + groupedAlbums: groupedAlbums, + ), + ], + ), + ), + ), + + // Bottom Selection Action Bar + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), + child: _buildSelectionBottomBar( + context, + colorScheme, + _filterHistoryItems(allHistoryItems, historyFilterMode), + bottomPadding, + ), + ), + ], + ), + ); + } + + /// Build content for each filter tab + Widget _buildFilterContent({ + required BuildContext context, + required ColorScheme colorScheme, + required String filterMode, + required List allHistoryItems, + required String historyViewMode, + required List queueItems, + required List<_GroupedAlbum> groupedAlbums, + }) { + final historyItems = _filterHistoryItems(allHistoryItems, filterMode); + + return CustomScrollView( + slivers: [ + // History section header + if (historyItems.isNotEmpty && + queueItems.isEmpty && + filterMode != 'albums') + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + Text( + '${historyItems.length} ${historyItems.length == 1 ? 'track' : 'tracks'}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const Spacer(), + if (!_isSelectionMode) + TextButton.icon( + onPressed: historyItems.isNotEmpty + ? () => _enterSelectionMode(historyItems.first.id) + : null, + icon: const Icon(Icons.checklist, size: 18), + label: const Text('Select'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ), + ), + + // Albums section header (when Albums filter is selected) + if (groupedAlbums.isNotEmpty && + queueItems.isEmpty && + filterMode == 'albums') + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + '${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), ), + ), - // History - Grid or List (for All and Singles filter) - if (historyItems.isNotEmpty && historyFilterMode != 'albums') - historyViewMode == 'grid' - ? SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 0.75, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final item = historyItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), - child: _buildHistoryGridItem(context, item, colorScheme), - ); - }, - childCount: historyItems.length, - ), - ), - ) - : SliverList(delegate: SliverChildBuilderDelegate( - (context, index) { + // History section header when queue has items + if (historyItems.isNotEmpty && queueItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Downloaded', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + + // Albums Grid (when Albums filter is selected) + if (filterMode == 'albums' && groupedAlbums.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final album = groupedAlbums[index]; + return KeyedSubtree( + key: ValueKey(album.key), + child: _buildAlbumGridItem(context, album, colorScheme), + ); + }, childCount: groupedAlbums.length), + ), + ), + + // History - Grid or List (for All and Singles filter) + if (historyItems.isNotEmpty && filterMode != 'albums') + historyViewMode == 'grid' + ? SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 0.75, + ), + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { final item = historyItems[index]; return KeyedSubtree( key: ValueKey(item.id), - child: _buildHistoryItem(context, item, colorScheme), + child: _buildHistoryGridItem( + context, + item, + colorScheme, + ), ); - }, - childCount: historyItems.length, - )), + }, childCount: historyItems.length), + ), + ) + : SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = historyItems[index]; + return KeyedSubtree( + key: ValueKey(item.id), + child: _buildHistoryItem( + context, + item, + colorScheme, + ), + ); + }, childCount: historyItems.length), + ), - // Empty state - if (queueItems.isEmpty && historyItems.isEmpty && (historyFilterMode != 'albums' || groupedAlbums.isEmpty)) - SliverFillRemaining(hasScrollBody: false, child: _buildEmptyState(context, colorScheme, historyFilterMode)) - else - // Add bottom padding when selection mode is active to avoid overlap with bottom bar - SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 100 : 16)), - ], - ), - - // Bottom Selection Action Bar - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - left: 0, - right: 0, - bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), - child: _buildSelectionBottomBar(context, colorScheme, historyItems, bottomPadding), - ), - ], - ), -); + // Empty state + if (queueItems.isEmpty && + historyItems.isEmpty && + (filterMode != 'albums' || groupedAlbums.isEmpty)) + SliverFillRemaining( + hasScrollBody: false, + child: _buildEmptyState( + context, + colorScheme, + filterMode, + ), + ) + else + // Add bottom padding when selection mode is active to avoid overlap with bottom bar + SliverToBoxAdapter( + child: SizedBox(height: _isSelectionMode ? 100 : 16), + ), + ], + ); } - Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme, String filterMode) { + Widget _buildEmptyState( + BuildContext context, + ColorScheme colorScheme, + String filterMode, + ) { String message; String subtitle; IconData icon; - + switch (filterMode) { case 'albums': message = 'No album downloads'; @@ -635,20 +956,37 @@ class _QueueTabState extends ConsumerState { subtitle = 'Downloaded tracks will appear here'; icon = Icons.history; } - + return Center( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 64, color: colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text(message, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7))), - ]), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64, color: colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text( + message, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), ); } /// Build album grid item for grouped albums view - Widget _buildAlbumGridItem(BuildContext context, _GroupedAlbum album, ColorScheme colorScheme) { + Widget _buildAlbumGridItem( + BuildContext context, + _GroupedAlbum album, + ColorScheme colorScheme, + ) { return GestureDetector( onTap: () => _navigateToDownloadedAlbum(album), child: Column( @@ -671,7 +1009,13 @@ class _QueueTabState extends ConsumerState { ) : Container( color: colorScheme.surfaceContainerHighest, - child: Center(child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 48)), + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, + ), + ), ), ), // Track count badge @@ -679,7 +1023,10 @@ class _QueueTabState extends ConsumerState { right: 8, bottom: 8, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), @@ -687,7 +1034,11 @@ class _QueueTabState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.music_note, size: 12, color: colorScheme.onPrimaryContainer), + Icon( + Icons.music_note, + size: 12, + color: colorScheme.onPrimaryContainer, + ), const SizedBox(width: 4), Text( '${album.tracks.length}', @@ -710,14 +1061,18 @@ class _QueueTabState extends ConsumerState { album.albumName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), // Artist name Text( album.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -725,10 +1080,16 @@ class _QueueTabState extends ConsumerState { } /// Bottom action bar for selection mode (Material Design 3 style) - Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List historyItems, double bottomPadding) { + Widget _buildSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List historyItems, + double bottomPadding, + ) { final selectedCount = _selectedIds.length; - final allSelected = selectedCount == historyItems.length && historyItems.isNotEmpty; - + final allSelected = + selectedCount == historyItems.length && historyItems.isNotEmpty; + return Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerHigh, @@ -758,7 +1119,7 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(2), ), ), - + // Selection info row Row( children: [ @@ -771,7 +1132,7 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(width: 12), - + // Selection count Expanded( child: Column( @@ -779,20 +1140,20 @@ class _QueueTabState extends ConsumerState { children: [ Text( '$selectedCount selected', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), ), Text( - allSelected ? 'All tracks selected' : 'Tap tracks to select', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + allSelected + ? 'All tracks selected' + : 'Tap tracks to select', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], ), ), - + // Select all toggle TextButton.icon( onPressed: () { @@ -813,9 +1174,9 @@ class _QueueTabState extends ConsumerState { ), ], ), - + const SizedBox(height: 16), - + // Delete button SizedBox( width: double.infinity, @@ -823,13 +1184,17 @@ class _QueueTabState extends ConsumerState { onPressed: selectedCount > 0 ? _deleteSelected : null, icon: const Icon(Icons.delete_outline), label: Text( - selectedCount > 0 + selectedCount > 0 ? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}' : 'Select tracks to delete', ), style: FilledButton.styleFrom( - backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, - foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, + backgroundColor: selectedCount > 0 + ? colorScheme.error + : colorScheme.surfaceContainerHighest, + foregroundColor: selectedCount > 0 + ? colorScheme.onError + : colorScheme.onSurfaceVariant, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -844,9 +1209,13 @@ class _QueueTabState extends ConsumerState { ); } - Widget _buildQueueItem(BuildContext context, DownloadItem item, ColorScheme colorScheme) { + Widget _buildQueueItem( + BuildContext context, + DownloadItem item, + ColorScheme colorScheme, + ) { final isCompleted = item.status == DownloadStatus.completed; - + return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: InkWell( @@ -857,7 +1226,10 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ isCompleted - ? Hero(tag: 'cover_${item.id}', child: _buildCoverArt(item, colorScheme)) + ? Hero( + tag: 'cover_${item.id}', + child: _buildCoverArt(item, colorScheme), + ) : _buildCoverArt(item, colorScheme), const SizedBox(width: 12), Expanded( @@ -868,14 +1240,18 @@ class _QueueTabState extends ConsumerState { item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 2), Text( item.track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), if (item.status == DownloadStatus.downloading) ...[ const SizedBox(height: 8), @@ -886,7 +1262,8 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: item.progress > 0 ? item.progress : null, - backgroundColor: colorScheme.surfaceContainerHighest, + backgroundColor: + colorScheme.surfaceContainerHighest, color: colorScheme.primary, minHeight: 6, ), @@ -894,13 +1271,14 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 8), Text( - item.speedMBps > 0 + item.speedMBps > 0 ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' : '${(item.progress * 100).toStringAsFixed(0)}%', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), ), ], ), @@ -911,7 +1289,9 @@ class _QueueTabState extends ConsumerState { item.errorMessage, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.error), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.error, + ), ), ], ], @@ -950,21 +1330,31 @@ class _QueueTabState extends ConsumerState { ); } - Widget _buildActionButtons(BuildContext context, DownloadItem item, ColorScheme colorScheme) { + Widget _buildActionButtons( + BuildContext context, + DownloadItem item, + ColorScheme colorScheme, + ) { switch (item.status) { case DownloadStatus.queued: return IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), + onPressed: () => + ref.read(downloadQueueProvider.notifier).cancelItem(item.id), icon: Icon(Icons.close, color: colorScheme.error), tooltip: 'Cancel', - style: IconButton.styleFrom(backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3)), + style: IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), + ), ); case DownloadStatus.downloading: return IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), + onPressed: () => + ref.read(downloadQueueProvider.notifier).cancelItem(item.id), icon: Icon(Icons.stop, color: colorScheme.error), tooltip: 'Stop', - style: IconButton.styleFrom(backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3)), + style: IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), + ), ); case DownloadStatus.finalizing: return SizedBox( @@ -973,7 +1363,10 @@ class _QueueTabState extends ConsumerState { child: Stack( alignment: Alignment.center, children: [ - CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary), + CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.tertiary, + ), Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), ], ), @@ -988,15 +1381,26 @@ class _QueueTabState extends ConsumerState { onPressed: () => _openFile(item.filePath!), icon: Icon(Icons.play_arrow, color: colorScheme.primary), tooltip: 'Play', - style: IconButton.styleFrom(backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3)), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), ) else Icon(Icons.error_outline, color: colorScheme.error, size: 20), const SizedBox(width: 4), Container( padding: const EdgeInsets.all(8), - decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), - child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: 20), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: colorScheme.onPrimaryContainer, + size: 20, + ), ), ], ); @@ -1006,18 +1410,33 @@ class _QueueTabState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id), + onPressed: () => + ref.read(downloadQueueProvider.notifier).retryItem(item.id), icon: Icon(Icons.refresh, color: colorScheme.primary), tooltip: 'Retry', - style: IconButton.styleFrom(backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3)), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), ), const SizedBox(width: 4), IconButton( - onPressed: () => ref.read(downloadQueueProvider.notifier).removeItem(item.id), - icon: Icon(Icons.close, color: item.status == DownloadStatus.failed ? colorScheme.error : colorScheme.onSurfaceVariant), + onPressed: () => + ref.read(downloadQueueProvider.notifier).removeItem(item.id), + icon: Icon( + Icons.close, + color: item.status == DownloadStatus.failed + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), tooltip: 'Remove', - style: item.status == DownloadStatus.failed - ? IconButton.styleFrom(backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3)) + style: item.status == DownloadStatus.failed + ? IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer.withValues( + alpha: 0.3, + ), + ) : null, ), ], @@ -1025,12 +1444,16 @@ class _QueueTabState extends ConsumerState { } } - Widget _buildHistoryGridItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) { + Widget _buildHistoryGridItem( + BuildContext context, + DownloadHistoryItem item, + ColorScheme colorScheme, + ) { final fileExists = _checkFileExists(item.filePath); final isSelected = _selectedIds.contains(item.id); - + return GestureDetector( - onTap: _isSelectionMode + onTap: _isSelectionMode ? () => _toggleSelection(item.id) : () => _navigateToHistoryMetadataScreen(item), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), @@ -1054,7 +1477,11 @@ class _QueueTabState extends ConsumerState { ) : Container( color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 32), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 32, + ), ), ), ), @@ -1064,18 +1491,26 @@ class _QueueTabState extends ConsumerState { left: 4, top: 4, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), decoration: BoxDecoration( - color: item.quality!.startsWith('24') ? colorScheme.tertiary : colorScheme.surfaceContainerHighest, + color: item.quality!.startsWith('24') + ? colorScheme.tertiary + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( item.quality!.split('/').first, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: item.quality!.startsWith('24') ? colorScheme.onTertiary : colorScheme.onSurfaceVariant, - fontSize: 9, - fontWeight: FontWeight.w600, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onTertiary + : colorScheme.onSurfaceVariant, + fontSize: 9, + fontWeight: FontWeight.w600, + ), ), ), ), @@ -1088,8 +1523,15 @@ class _QueueTabState extends ConsumerState { onTap: () => _openFile(item.filePath), child: Container( padding: const EdgeInsets.all(6), - decoration: BoxDecoration(color: colorScheme.primary, shape: BoxShape.circle), - child: Icon(Icons.play_arrow, color: colorScheme.onPrimary, size: 16), + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon( + Icons.play_arrow, + color: colorScheme.onPrimary, + size: 16, + ), ), ), ), @@ -1100,8 +1542,15 @@ class _QueueTabState extends ConsumerState { bottom: 4, child: Container( padding: const EdgeInsets.all(4), - decoration: BoxDecoration(color: colorScheme.errorContainer, shape: BoxShape.circle), - child: Icon(Icons.error_outline, color: colorScheme.error, size: 14), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 14, + ), ), ), // Selection overlay @@ -1109,7 +1558,9 @@ class _QueueTabState extends ConsumerState { Positioned.fill( child: Container( decoration: BoxDecoration( - color: isSelected ? colorScheme.primary.withValues(alpha: 0.3) : Colors.transparent, + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, borderRadius: BorderRadius.circular(8), ), ), @@ -1121,13 +1572,17 @@ class _QueueTabState extends ConsumerState { item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), ), Text( item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -1140,9 +1595,14 @@ class _QueueTabState extends ConsumerState { decoration: BoxDecoration( color: isSelected ? colorScheme.primary : colorScheme.surface, shape: BoxShape.circle, - border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), ), - child: isSelected + child: isSelected ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) : const SizedBox(width: 16, height: 16), ), @@ -1152,22 +1612,43 @@ class _QueueTabState extends ConsumerState { ); } - - Widget _buildHistoryItem(BuildContext context, DownloadHistoryItem item, ColorScheme colorScheme) { + Widget _buildHistoryItem( + BuildContext context, + DownloadHistoryItem item, + ColorScheme colorScheme, + ) { final fileExists = _checkFileExists(item.filePath); final isSelected = _selectedIds.contains(item.id); final date = item.downloadedAt; - final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - final dateStr = '${months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - + final months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final dateStr = + '${months[date.month - 1]} ${date.day}, ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : null, + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, child: InkWell( - onTap: _isSelectionMode + onTap: _isSelectionMode ? () => _toggleSelection(item.id) : () => _navigateToHistoryMetadataScreen(item), - onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(item.id), borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(12), @@ -1179,12 +1660,23 @@ class _QueueTabState extends ConsumerState { width: 24, height: 24, decoration: BoxDecoration( - color: isSelected ? colorScheme.primary : Colors.transparent, + color: isSelected + ? colorScheme.primary + : Colors.transparent, shape: BoxShape.circle, - border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), ), - child: isSelected - ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) + child: isSelected + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 16, + ) : null, ), const SizedBox(width: 12), @@ -1209,10 +1701,13 @@ class _QueueTabState extends ConsumerState { color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), ), const SizedBox(width: 12), - + // Track info Expanded( child: Column( @@ -1222,43 +1717,55 @@ class _QueueTabState extends ConsumerState { item.trackName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 2), Text( item.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 2), Row( children: [ Text( dateStr, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.7, + ), + ), ), - if (item.quality != null && item.quality!.contains('bit')) ...[ + if (item.quality != null && + item.quality!.contains('bit')) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.tertiaryContainer + color: item.quality!.startsWith('24') + ? colorScheme.tertiaryContainer : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( item.quality!, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onTertiaryContainer - : colorScheme.onSurfaceVariant, - fontSize: 10, - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onTertiaryContainer + : colorScheme.onSurfaceVariant, + fontSize: 10, + fontWeight: FontWeight.w500, + ), ), ), ], @@ -1268,7 +1775,7 @@ class _QueueTabState extends ConsumerState { ), ), const SizedBox(width: 8), - + // Action buttons (hide in selection mode) if (!_isSelectionMode) Row( @@ -1277,14 +1784,22 @@ class _QueueTabState extends ConsumerState { if (fileExists) IconButton( onPressed: () => _openFile(item.filePath), - icon: Icon(Icons.play_arrow, color: colorScheme.primary), + icon: Icon( + Icons.play_arrow, + color: colorScheme.primary, + ), tooltip: 'Play', style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + backgroundColor: colorScheme.primaryContainer + .withValues(alpha: 0.3), ), ) else - Icon(Icons.error_outline, color: colorScheme.error, size: 20), + Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), ], ), ], @@ -1312,9 +1827,11 @@ class _FilterChip extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Material( - color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + color: isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), child: InkWell( onTap: onTap, @@ -1327,7 +1844,9 @@ class _FilterChip extends StatelessWidget { Text( label, style: TextStyle( - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), @@ -1335,8 +1854,8 @@ class _FilterChip extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.2) + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.2) : colorScheme.outline.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(10), ), @@ -1344,7 +1863,9 @@ class _FilterChip extends StatelessWidget { count.toString(), style: TextStyle( fontSize: 11, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, fontWeight: FontWeight.w500, ), ), diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index c29298f5..a17857f3 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -13,45 +13,45 @@ 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); + // 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, + ), + ), + ); + }, + ), + ), // App header card with logo and description SliverToBoxAdapter( @@ -220,7 +220,7 @@ class AboutPage extends StatelessWidget { const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), - ), + ), ); } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 84129130..63e3d048 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -15,27 +15,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: 'Appearance', + topPadding: topPadding, + ), + ), // Preview Section SliverToBoxAdapter( diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 055278dd..7ce58d9d 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -4,61 +4,68 @@ 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/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; 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 Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); 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( + 'Download', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), // Service section const SliverToBoxAdapter( @@ -87,13 +94,17 @@ class DownloadSettingsPage extends ConsumerWidget { SettingsSwitchItem( icon: Icons.tune, title: 'Ask Before Download', - subtitle: 'Choose quality for each download', + subtitle: isBuiltInService + ? 'Choose quality for each download' + : 'Select a built-in service to enable', value: settings.askQualityBeforeDownload, + // Not selected visually if extension is active + enabled: isBuiltInService, onChanged: (value) => ref .read(settingsProvider.notifier) .setAskQualityBeforeDownload(value), ), - if (!settings.askQualityBeforeDownload) ...[ + if (!settings.askQualityBeforeDownload && isBuiltInService) ...[ _QualityOption( title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', @@ -120,6 +131,29 @@ class DownloadSettingsPage extends ConsumerWidget { showDivider: false, ), ], + if (!isBuiltInService) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Select Tidal, Qobuz, or Amazon above to configure quality', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], ], ), ), @@ -151,19 +185,44 @@ class DownloadSettingsPage extends ConsumerWidget { : settings.downloadDirectory, onTap: () => _pickDirectory(context, ref), ), - SettingsItem( - icon: Icons.create_new_folder_outlined, - title: 'Folder Organization', - subtitle: _getFolderOrganizationLabel( - settings.folderOrganization, - ), - onTap: () => _showFolderOrganizationPicker( - context, - ref, - settings.folderOrganization, - ), - showDivider: false, + SettingsSwitchItem( + icon: Icons.library_music_outlined, + title: 'Separate Singles Folder', + subtitle: settings.separateSingles + ? 'Albums/ and Singles/ folders' + : 'All files in same structure', + value: settings.separateSingles, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setSeparateSingles(value), ), + if (settings.separateSingles) + SettingsItem( + icon: Icons.folder_outlined, + title: 'Album Folder Structure', + subtitle: settings.albumFolderStructure == 'album_only' + ? 'Albums/Album Name/' + : 'Albums/Artist/Album Name/', + onTap: () => _showAlbumFolderStructurePicker( + context, + ref, + settings.albumFolderStructure, + ), + ), + if (!settings.separateSingles) + SettingsItem( + icon: Icons.create_new_folder_outlined, + title: 'Folder Organization', + subtitle: _getFolderOrganizationLabel( + settings.folderOrganization, + ), + onTap: () => _showFolderOrganizationPicker( + context, + ref, + settings.folderOrganization, + ), + showDivider: false, + ), ], ), ), @@ -175,6 +234,39 @@ class DownloadSettingsPage extends ConsumerWidget { ); } + 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: const Text('Artist / Album'), + subtitle: const Text('Albums/Artist Name/Album Name/'), + 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.album_outlined), + title: const Text('Album Only'), + subtitle: const Text('Albums/Album Name/'), + trailing: current == 'album_only' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only'); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + void _showFormatEditor(BuildContext context, WidgetRef ref, String current) { final controller = TextEditingController(text: current); final colorScheme = Theme.of(context).colorScheme; @@ -481,81 +573,87 @@ 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( + 'Organize downloaded files into folders', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + _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), + ], + ), ), ), ); } } -class _ServiceSelector extends StatelessWidget { +class _ServiceSelector extends ConsumerWidget { final String currentService; final ValueChanged onChanged; const _ServiceSelector({ @@ -564,31 +662,75 @@ class _ServiceSelector extends StatelessWidget { }); @override - Widget build(BuildContext context) { + 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( padding: const EdgeInsets.all(12), - child: Row( + child: Column( children: [ - _ServiceChip( - icon: Icons.music_note, - label: 'Tidal', - isSelected: currentService == 'tidal', - onTap: () => onChanged('tidal'), - ), - const SizedBox(width: 8), - _ServiceChip( - icon: Icons.album, - label: 'Qobuz', - isSelected: currentService == 'qobuz', - onTap: () => onChanged('qobuz'), - ), - const SizedBox(width: 8), - _ServiceChip( - icon: Icons.shopping_bag, - label: 'Amazon', - isSelected: currentService == 'amazon', - onTap: () => onChanged('amazon'), + Row( + children: [ + _ServiceChip( + icon: Icons.music_note, + label: 'Tidal', + isSelected: effectiveService == 'tidal', + onTap: () => onChanged('tidal'), + ), + const SizedBox(width: 8), + _ServiceChip( + icon: Icons.album, + label: 'Qobuz', + isSelected: effectiveService == 'qobuz', + onTap: () => onChanged('qobuz'), + ), + const SizedBox(width: 8), + _ServiceChip( + icon: Icons.shopping_bag, + label: 'Amazon', + isSelected: effectiveService == 'amazon', + onTap: () => onChanged('amazon'), + ), + ], ), + // Show extension download providers if any + if (extensionProviders.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + for (int i = 0; i < extensionProviders.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded( + child: _ServiceChip( + icon: Icons.extension, + label: extensionProviders[i].displayName, + isSelected: effectiveService == extensionProviders[i].id, + onTap: () => onChanged(extensionProviders[i].id), + ), + ), + ], + // Fill remaining space if less than 3 extensions + for (int i = extensionProviders.length; i < 3; i++) ...[ + const SizedBox(width: 8), + const Expanded(child: SizedBox()), + ], + ], + ), + ], ], ), ); diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart new file mode 100644 index 00000000..0144111e --- /dev/null +++ b/lib/screens/settings/extension_detail_page.dart @@ -0,0 +1,1065 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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'; + +class ExtensionDetailPage extends ConsumerStatefulWidget { + final String extensionId; + + const ExtensionDetailPage({super.key, required this.extensionId}); + + @override + ConsumerState createState() => _ExtensionDetailPageState(); +} + +class _ExtensionDetailPageState extends ConsumerState { + Map _settings = {}; + bool _isLoadingSettings = true; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final settings = await ref + .read(extensionProvider.notifier) + .getExtensionSettings(widget.extensionId); + setState(() { + _settings = settings; + _isLoadingSettings = false; + }); + } + + @override + Widget build(BuildContext context) { + final extState = ref.watch(extensionProvider); + final extension = extState.extensions.firstWhere( + (e) => e.id == widget.extensionId, + orElse: () => const Extension( + id: '', + name: '', + displayName: 'Unknown', + version: '0.0.0', + author: 'Unknown', + description: '', + enabled: false, + status: 'error', + ), + ); + + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + final hasError = extension.status == 'error'; + + return PopScope( + canPop: true, // Always allow back gesture + child: Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + extension.displayName, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // Extension Info Card + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: hasError + ? colorScheme.errorContainer + : colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: extension.iconPath != null && extension.iconPath!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.file( + File(extension.iconPath!), + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + hasError ? Icons.error_outline : Icons.extension, + size: 28, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, + ), + ), + ) + : Icon( + hasError ? Icons.error_outline : Icons.extension, + size: 28, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + extension.displayName, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + 'v${extension.version}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Switch( + value: extension.enabled, + onChanged: hasError + ? null + : (enabled) => ref + .read(extensionProvider.notifier) + .setExtensionEnabled(widget.extensionId, enabled), + ), + ], + ), + if (extension.description.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + extension.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 16), + _InfoRow(label: 'Author', value: extension.author), + _InfoRow(label: 'ID', value: extension.id), + _InfoRow(label: 'Version', value: 'v${extension.version}'), + if (hasError && extension.errorMessage != null) + _InfoRow( + label: 'Error', + value: extension.errorMessage!, + isError: true, + ), + ], + ), + ), + ), + ), + + // Capabilities + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Capabilities'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _CapabilityItem( + icon: Icons.search, + title: 'Metadata Provider', + enabled: extension.hasMetadataProvider, + ), + _CapabilityItem( + icon: Icons.download, + title: 'Download Provider', + enabled: extension.hasDownloadProvider, + ), + _CapabilityItem( + icon: Icons.manage_search, + title: 'Custom Search', + enabled: extension.hasCustomSearch, + subtitle: extension.searchBehavior?.placeholder, + ), + _CapabilityItem( + icon: Icons.compare_arrows, + title: 'Custom Track Matching', + enabled: extension.hasCustomMatching, + subtitle: extension.trackMatching?.strategy != null + ? 'Strategy: ${extension.trackMatching!.strategy}' + : null, + ), + _CapabilityItem( + icon: Icons.auto_fix_high, + title: 'Post-Processing', + enabled: extension.hasPostProcessing, + subtitle: extension.postProcessing?.hooks.isNotEmpty == true + ? '${extension.postProcessing!.hooks.length} hook(s) available' + : null, + ), + _CapabilityItem( + icon: Icons.link, + title: 'URL Handler', + enabled: extension.hasURLHandler, + subtitle: extension.urlHandler?.patterns.isNotEmpty == true + ? '${extension.urlHandler!.patterns.length} pattern(s)' + : null, + showDivider: false, + ), + ], + ), + ), + + + + // URL Handler Section (if extension handles URLs) + if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'URL Handler'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _URLHandlerInfo( + patterns: extension.urlHandler!.patterns, + ), + ], + ), + ), + ], + + // Quality Options Section (for download providers) + if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Quality Options'), + ), + 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(), + ), + ), + ], + + // Post-Processing Hooks (if available) + if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Post-Processing Hooks'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.postProcessing!.hooks.asMap().entries.map((entry) { + final index = entry.key; + final hook = entry.value; + return _PostProcessingHookItem( + hook: hook, + showDivider: index < extension.postProcessing!.hooks.length - 1, + ); + }).toList(), + ), + ), + ], + + // Permissions + if (extension.permissions.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Permissions'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.permissions.asMap().entries.map((entry) { + final index = entry.key; + final permission = entry.value; + return _PermissionItem( + permission: permission, + showDivider: index < extension.permissions.length - 1, + ); + }).toList(), + ), + ), + ], + + // Settings + if (extension.settings.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Settings'), + ), + if (_isLoadingSettings) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ) + else + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.settings.asMap().entries.map((entry) { + final index = entry.key; + final setting = entry.value; + return _SettingItem( + setting: setting, + value: _settings[setting.key] ?? setting.defaultValue, + showDivider: index < extension.settings.length - 1, + onChanged: (value) => _updateSetting(setting.key, value), + ); + }).toList(), + ), + ), + ], + + // 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'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + Future _updateSetting(String key, dynamic value) async { + setState(() { + _settings[key] = value; + }); + await ref + .read(extensionProvider.notifier) + .setExtensionSettings(widget.extensionId, _settings); + } + + Future _confirmRemove(BuildContext context) async { + final colorScheme = Theme.of(context).colorScheme; + final confirmed = await showDialog( + 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.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + ), + child: const Text('Remove'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final success = await ref + .read(extensionProvider.notifier) + .removeExtension(widget.extensionId); + if (success && mounted) { + // Refresh store to update isInstalled status + ref.read(storeProvider.notifier).refresh(); + Navigator.pop(this.context); + } + } + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + final bool isError; + + const _InfoRow({ + required this.label, + required this.value, + this.isError = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 60, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isError ? colorScheme.error : colorScheme.onSurface, + ), + ), + ), + ], + ), + ); + } +} + +class _CapabilityItem extends StatelessWidget { + final IconData icon; + final String title; + final bool enabled; + final bool showDivider; + final String? subtitle; + + const _CapabilityItem({ + required this.icon, + required this.title, + required this.enabled, + this.showDivider = true, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + icon, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (subtitle != null && enabled) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + Icon( + enabled ? Icons.check_circle : Icons.cancel_outlined, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + ], + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 56, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +class _PermissionItem extends StatelessWidget { + final String permission; + final bool showDivider; + + const _PermissionItem({ + required this.permission, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + // Parse permission to get icon and description + IconData icon = Icons.security; + String description = permission; + + if (permission.startsWith('network:')) { + icon = Icons.language; + description = 'Network access to: ${permission.substring(8)}'; + } else if (permission.startsWith('storage:')) { + icon = Icons.folder; + description = 'Storage access: ${permission.substring(8)}'; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 16), + Expanded( + child: Text( + description, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 56, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +class _SettingItem extends StatelessWidget { + final ExtensionSetting setting; + final dynamic value; + final bool showDivider; + final ValueChanged onChanged; + + const _SettingItem({ + required this.setting, + required this.value, + required this.onChanged, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + Widget trailing; + switch (setting.type) { + case 'boolean': + trailing = Switch( + value: value as bool? ?? false, + onChanged: onChanged, + ); + break; + case 'select': + trailing = DropdownButton( + value: value as String?, + items: setting.options?.map((opt) { + return DropdownMenuItem(value: opt, child: Text(opt)); + }).toList(), + onChanged: onChanged, + underline: const SizedBox(), + ); + break; + default: + trailing = Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: setting.type == 'string' || setting.type == 'number' + ? () => _showEditDialog(context) + : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + setting.label, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (setting.description != null) ...[ + const SizedBox(height: 2), + Text( + setting.description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (setting.type == 'string' || setting.type == 'number') ...[ + const SizedBox(height: 4), + Text( + value?.toString() ?? 'Not set', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ], + ), + ), + trailing, + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 16, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } + + void _showEditDialog(BuildContext context) { + final controller = TextEditingController(text: value?.toString() ?? ''); + final colorScheme = Theme.of(context).colorScheme; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(setting.label), + content: TextField( + controller: controller, + keyboardType: setting.type == 'number' + ? TextInputType.number + : TextInputType.text, + decoration: InputDecoration( + hintText: setting.description ?? 'Enter value', + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final newValue = setting.type == 'number' + ? num.tryParse(controller.text) + : controller.text; + onChanged(newValue); + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ), + ); + } +} + +class _PostProcessingHookItem extends StatelessWidget { + final PostProcessingHook hook; + final bool showDivider; + + const _PostProcessingHookItem({ + required this.hook, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.auto_fix_high, + color: colorScheme.onTertiaryContainer, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hook.name, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (hook.description != null) ...[ + const SizedBox(height: 2), + Text( + hook.description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (hook.supportedFormats.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 4, + children: hook.supportedFormats.map((format) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + format.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + if (hook.defaultEnabled) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Auto', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 72, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + + + +class _URLHandlerInfo extends StatelessWidget { + final List patterns; + + const _URLHandlerInfo({ + required this.patterns, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.link, + color: colorScheme.onTertiaryContainer, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Custom URL Handling', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'This extension can handle links from these sites', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + 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), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Share links from these sites to SpotiFLAC and this extension will handle them.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _QualityOptionItem extends StatelessWidget { + final QualityOption quality; + final bool showDivider; + + const _QualityOptionItem({ + required this.quality, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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, + ), + ), + ), + ], + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 72, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart new file mode 100644 index 00000000..b4a86143 --- /dev/null +++ b/lib/screens/settings/extensions_page.dart @@ -0,0 +1,724 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.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'; +import 'package:spotiflac_android/screens/settings/provider_priority_page.dart'; +import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class ExtensionsPage extends ConsumerStatefulWidget { + const ExtensionsPage({super.key}); + + @override + ConsumerState createState() => _ExtensionsPageState(); +} + +class _ExtensionsPageState extends ConsumerState { + @override + void initState() { + super.initState(); + _initializeExtensions(); + } + + Future _initializeExtensions() async { + final extState = ref.read(extensionProvider); + if (!extState.isInitialized) { + final appDir = await getApplicationDocumentsDirectory(); + 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); + + await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); + } + } + + @override + Widget build(BuildContext context) { + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return PopScope( + canPop: true, // Always allow back gesture + child: Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Extensions', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // Loading indicator + if (extState.isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + + // Error message + if (extState.error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + extState.error!, + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + ], + ), + ), + ), + ), + + // Provider Priority + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Provider Priority'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _DownloadPriorityItem(), + _MetadataPriorityItem(), + _SearchProviderSelector(), + ], + ), + ), + + // Installed Extensions + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Installed Extensions'), + ), + + if (extState.extensions.isEmpty && !extState.isLoading) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + Icons.extension_outlined, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No extensions installed', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + 'Install .spotiflac-ext files to add new providers', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + + if (extState.extensions.isNotEmpty) + SliverToBoxAdapter( + child: SettingsGroup( + children: extState.extensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < extState.extensions.length - 1, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ExtensionDetailPage(extensionId: ext.id), + ), + ), + onToggle: (enabled) => ref + .read(extensionProvider.notifier) + .setExtensionEnabled(ext.id, enabled), + ); + }).toList(), + ), + ), + + // 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'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + + // Info section + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Extensions can add new metadata and download providers. ' + 'Only install extensions from trusted sources.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + Future _installExtension() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + allowMultiple: false, + ); + + if (result != null && result.files.isNotEmpty) { + final file = result.files.first; + if (file.path != null) { + if (!file.path!.endsWith('.spotiflac-ext')) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please select a .spotiflac-ext file'), + ), + ); + } + return; + } + + final success = await ref + .read(extensionProvider.notifier) + .installExtension(file.path!); + + if (mounted) { + final extState = ref.read(extensionProvider); + String message; + if (success) { + message = 'Extension installed successfully'; + } 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( + SnackBar(content: Text(message)), + ); + } + } + } + } + + /// Parse error message to be more user-friendly + String _getFriendlyErrorMessage(String? error) { + if (error == null) return 'Failed to install extension'; + + 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; + } + } + } + + // Clean up any remaining artifacts + message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), ''); + message = message.replaceAll(RegExp(r'^\s*,\s*'), ''); + + return message; + } +} + +class _ExtensionItem extends StatelessWidget { + final Extension extension; + final bool showDivider; + final VoidCallback onTap; + final ValueChanged onToggle; + + const _ExtensionItem({ + required this.extension, + required this.showDivider, + required this.onTap, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final hasError = extension.status == 'error'; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Extension icon + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: hasError + ? colorScheme.errorContainer + : colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: extension.iconPath != null && extension.iconPath!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(extension.iconPath!), + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + hasError ? Icons.error_outline : Icons.extension, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, + ), + ), + ) + : Icon( + hasError ? Icons.error_outline : Icons.extension, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + // Extension info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + extension.displayName, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + hasError + ? extension.errorMessage ?? 'Error loading extension' + : 'v${extension.version} by ${extension.author}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: hasError + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Toggle switch + Switch( + value: extension.enabled, + onChanged: hasError ? null : onToggle, + ), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 76, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +class _DownloadPriorityItem extends ConsumerWidget { + const _DownloadPriorityItem(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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); + + return InkWell( + onTap: hasDownloadExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ProviderPriorityPage(), + ), + ) + : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.download, + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant + : colorScheme.outline, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Download Priority', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: hasDownloadExtensions + ? null + : colorScheme.outline, + ), + ), + const SizedBox(height: 2), + Text( + hasDownloadExtensions + ? 'Set download service order' + : 'No extensions with download provider', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant + : colorScheme.outline, + ), + ], + ), + ), + ); + } +} + +class _MetadataPriorityItem extends ConsumerWidget { + const _MetadataPriorityItem(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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); + + return InkWell( + onTap: hasMetadataExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MetadataProviderPriorityPage(), + ), + ) + : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.search, + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant + : colorScheme.outline, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Metadata Priority', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: hasMetadataExtensions + ? null + : colorScheme.outline, + ), + ), + const SizedBox(height: 2), + Text( + hasMetadataExtensions + ? 'Set search & metadata source order' + : 'No extensions with metadata provider', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant + : colorScheme.outline, + ), + ], + ), + ), + ); + } +} + +class _SearchProviderSelector extends ConsumerWidget { + const _SearchProviderSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + 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)'; + if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { + final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull; + currentProviderName = ext?.displayName ?? settings.searchProvider!; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: searchProviders.isEmpty + ? null + : () => _showSearchProviderPicker(context, ref, settings, searchProviders), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.manage_search, + color: searchProviders.isEmpty + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Search Provider', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: searchProviders.isEmpty + ? colorScheme.outline + : null, + ), + ), + const SizedBox(height: 2), + Text( + searchProviders.isEmpty + ? 'No extensions with custom search' + : currentProviderName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: searchProviders.isEmpty + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ], + ); + } + + void _showSearchProviderPicker( + BuildContext context, + WidgetRef ref, + dynamic settings, + List searchProviders, + ) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'Search Provider', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Choose which service to use for searching tracks', + 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'), + trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty) + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref.read(settingsProvider.notifier).setSearchProvider(null); + 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'), + trailing: settings.searchProvider == ext.id + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref.read(settingsProvider.notifier).setSearchProvider(ext.id); + Navigator.pop(ctx); + }, + )), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 46ba3c42..4c4ebeb7 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -125,59 +125,59 @@ class _LogScreenState extends State { 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( - 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( + 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, ), - const PopupMenuItem( - value: 'clear', - child: ListTile( - leading: Icon(Icons.delete_outline), + ), + const PopupMenuItem( + value: 'clear', + child: ListTile( + leading: Icon(Icons.delete_outline), title: Text('Clear logs'), contentPadding: EdgeInsets.zero, ), diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart new file mode 100644 index 00000000..d9327086 --- /dev/null +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -0,0 +1,366 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +class MetadataProviderPriorityPage extends ConsumerStatefulWidget { + const MetadataProviderPriorityPage({super.key}); + + @override + ConsumerState createState() => _MetadataProviderPriorityPageState(); +} + +class _MetadataProviderPriorityPageState extends ConsumerState { + late List _providers; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _loadProviders(); + } + + void _loadProviders() { + 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; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return PopScope( + canPop: !_hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar + 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: () async { + if (_hasChanges) { + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + } else { + Navigator.pop(context); + } + }, + ), + actions: [ + if (_hasChanges) + TextButton( + onPressed: _saveChanges, + child: const Text('Save'), + ), + ], + 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( + 'Metadata Priority', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // 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.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + // Provider list + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _providers.length, + itemBuilder: (context, index) { + final provider = _providers[index]; + return _MetadataProviderItem( + key: ValueKey(provider), + provider: provider, + index: index, + isFirst: index == 0, + isLast: index == _providers.length - 1, + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _providers.removeAt(oldIndex); + _providers.insert(newIndex, item); + _hasChanges = true; + }); + }, + ), + ), + + // Info section + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Deezer has no rate limits and is recommended as primary. ' + 'Spotify may rate limit after many requests.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + Future _confirmDiscard(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Discard Changes?'), + content: const Text('You have unsaved changes. Do you want to discard them?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Discard'), + ), + ], + ), + ); + return result ?? false; + } + + Future _saveChanges() async { + await ref.read(extensionProvider.notifier).setMetadataProviderPriority(_providers); + setState(() { + _hasChanges = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Metadata provider priority saved')), + ); + } + } +} + +class _MetadataProviderItem extends StatelessWidget { + final String provider; + final int index; + final bool isFirst; + final bool isLast; + + const _MetadataProviderItem({ + super.key, + required this.provider, + required this.index, + required this.isFirst, + required this.isLast, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + + final info = _getProviderInfo(provider); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + child: ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Priority number + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isFirst + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isFirst + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(width: 16), + // Provider icon + Icon( + info.icon, + color: info.isBuiltIn + ? colorScheme.primary + : colorScheme.secondary, + ), + const SizedBox(width: 12), + // Provider name + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.name, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + info.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Drag handle + Icon( + Icons.drag_handle, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } + + _MetadataProviderInfo _getProviderInfo(String provider) { + switch (provider) { + case 'deezer': + return _MetadataProviderInfo( + name: 'Deezer', + icon: Icons.album, + description: 'No rate limits', + isBuiltIn: true, + ); + case 'spotify': + return _MetadataProviderInfo( + name: 'Spotify', + icon: Icons.music_note, + description: 'May rate limit', + isBuiltIn: true, + ); + default: + // Extension provider + return _MetadataProviderInfo( + name: provider, + icon: Icons.extension, + description: 'Extension', + isBuiltIn: false, + ); + } + } +} + +class _MetadataProviderInfo { + final String name; + final IconData icon; + final String description; + final bool isBuiltIn; + + _MetadataProviderInfo({ + required this.name, + required this.icon, + required this.description, + required this.isBuiltIn, + }); +} diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index f9822fb6..e1e4f944 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.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'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class OptionsSettingsPage extends ConsumerWidget { @@ -11,53 +12,55 @@ class OptionsSettingsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); + final extensionState = ref.watch(extensionProvider); + final hasExtensions = extensionState.extensions.isNotEmpty; final colorScheme = Theme.of(context).colorScheme; 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( + 'Options', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), // Search Source section const SliverToBoxAdapter( @@ -73,38 +76,50 @@ class OptionsSettingsPage extends ConsumerWidget { .setMetadataSource(v), ), if (settings.metadataSource == 'spotify') ...[ - SettingsSwitchItem( - icon: Icons.toggle_on, - title: 'Use Custom Credentials', - subtitle: settings.useCustomSpotifyCredentials - ? 'Using your credentials' - : 'Using default credentials', - value: settings.useCustomSpotifyCredentials, - onChanged: (v) { - ref - .read(settingsProvider.notifier) - .setUseCustomSpotifyCredentials(v); - if (v && settings.spotifyClientId.isEmpty) { - _showSpotifyCredentialsDialog(context, ref, settings); - } - }, - showDivider: true, - ), + // Info card about Spotify credentials requirement + if (settings.spotifyClientId.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Spotify requires your own API credentials. Get them free from developer.spotify.com', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ), SettingsItem( icon: Icons.key, - title: 'Set Credentials', + title: 'Spotify Credentials', subtitle: settings.spotifyClientId.isNotEmpty ? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}' - : 'Not configured', + : 'Required - tap to configure', onTap: () => _showSpotifyCredentialsDialog(context, ref, settings), trailing: Icon( settings.spotifyClientId.isNotEmpty - ? Icons.edit - : Icons.add, + ? Icons.check_circle + : Icons.error_outline, color: settings.spotifyClientId.isNotEmpty - ? Theme.of(context).colorScheme.onSurfaceVariant - : Theme.of(context).colorScheme.primary, + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.error, size: 20, ), showDivider: false, @@ -129,6 +144,18 @@ class OptionsSettingsPage extends ConsumerWidget { onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v), ), + if (hasExtensions) + SettingsSwitchItem( + icon: Icons.extension, + title: 'Use Extension Providers', + subtitle: settings.useExtensionProviders + ? 'Extensions will be tried first' + : 'Using built-in providers only', + value: settings.useExtensionProviders, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setUseExtensionProviders(v), + ), SettingsSwitchItem( icon: Icons.lyrics, title: 'Embed Lyrics', @@ -175,6 +202,15 @@ class OptionsSettingsPage extends ConsumerWidget { SliverToBoxAdapter( child: SettingsGroup( children: [ + SettingsSwitchItem( + icon: Icons.store, + title: 'Extension Store', + subtitle: 'Show Store tab in navigation', + value: settings.showExtensionStore, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setShowExtensionStore(v), + ), SettingsSwitchItem( icon: Icons.system_update, title: 'Check for Updates', @@ -345,11 +381,15 @@ class OptionsSettingsPage extends ConsumerWidget { ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -380,11 +420,15 @@ class OptionsSettingsPage extends ConsumerWidget { ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -745,7 +789,7 @@ class _ChannelChip extends StatelessWidget { } } -class _MetadataSourceSelector extends StatelessWidget { +class _MetadataSourceSelector extends ConsumerWidget { final String currentSource; final ValueChanged onChanged; const _MetadataSourceSelector({ @@ -754,8 +798,25 @@ class _MetadataSourceSelector extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + 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 + .where((e) => e.id == settings.searchProvider && e.enabled) + .firstOrNull; + } + final hasExtensionSearch = activeExtension != null; + + String? extensionName; + if (hasExtensionSearch) { + extensionName = activeExtension.displayName; + } + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -769,9 +830,13 @@ class _MetadataSourceSelector extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'Service used when searching by track name.', + hasExtensionSearch + ? 'Using extension: $extensionName' + : 'Service used when searching by track name.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + color: hasExtensionSearch + ? colorScheme.primary + : colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 16), @@ -780,18 +845,53 @@ class _MetadataSourceSelector extends StatelessWidget { _SourceChip( icon: Icons.graphic_eq, label: 'Deezer', - isSelected: currentSource == 'deezer', - onTap: () => onChanged('deezer'), + // Not selected if extension is active + isSelected: currentSource == 'deezer' && !hasExtensionSearch, + onTap: () { + // If extension was active, reset it to default + if (hasExtensionSearch) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + } + onChanged('deezer'); + }, ), const SizedBox(width: 12), _SourceChip( icon: Icons.music_note, label: 'Spotify', - isSelected: currentSource == 'spotify', - onTap: () => onChanged('spotify'), + // Not selected if extension is active + isSelected: currentSource == 'spotify' && !hasExtensionSearch, + onTap: () { + // If extension was active, reset it to default + if (hasExtensionSearch) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + } + onChanged('spotify'); + }, ), ], ), + if (hasExtensionSearch) ...[ + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Tap Deezer or Spotify to switch back from extension', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], ], ), ); @@ -802,13 +902,17 @@ class _SourceChip extends StatelessWidget { final IconData icon; final String label; final bool isSelected; - final VoidCallback onTap; + final VoidCallback? onTap; + final String? badge; + final Color? badgeColor; const _SourceChip({ required this.icon, required this.label, required this.isSelected, - required this.onTap, + this.onTap, + this.badge, + this.badgeColor, }); @override @@ -854,6 +958,24 @@ 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, + ), + ), + ), + ], ], ), ), diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart new file mode 100644 index 00000000..34170337 --- /dev/null +++ b/lib/screens/settings/provider_priority_page.dart @@ -0,0 +1,369 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +class ProviderPriorityPage extends ConsumerStatefulWidget { + const ProviderPriorityPage({super.key}); + + @override + ConsumerState createState() => _ProviderPriorityPageState(); +} + +class _ProviderPriorityPageState extends ConsumerState { + late List _providers; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _loadProviders(); + } + + void _loadProviders() { + 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; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return PopScope( + canPop: !_hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar + 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: () async { + if (_hasChanges) { + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + } else { + Navigator.pop(context); + } + }, + ), + actions: [ + if (_hasChanges) + TextButton( + onPressed: _saveChanges, + child: const Text('Save'), + ), + ], + 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( + 'Provider Priority', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // 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.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + // Provider list + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _providers.length, + itemBuilder: (context, index) { + final provider = _providers[index]; + return _ProviderItem( + key: ValueKey(provider), + provider: provider, + index: index, + isFirst: index == 0, + isLast: index == _providers.length - 1, + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _providers.removeAt(oldIndex); + _providers.insert(newIndex, item); + _hasChanges = true; + }); + }, + ), + ), + + // Info section + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + 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.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + Future _confirmDiscard(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Discard Changes?'), + content: const Text('You have unsaved changes. Do you want to discard them?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Discard'), + ), + ], + ), + ); + return result ?? false; + } + + Future _saveChanges() async { + await ref.read(extensionProvider.notifier).setProviderPriority(_providers); + setState(() { + _hasChanges = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Provider priority saved')), + ); + } + } +} + +class _ProviderItem extends StatelessWidget { + final String provider; + final int index; + final bool isFirst; + final bool isLast; + + const _ProviderItem({ + super.key, + required this.provider, + required this.index, + required this.isFirst, + required this.isLast, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + + // Get provider info + final info = _getProviderInfo(provider); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + child: ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Priority number + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isFirst + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isFirst + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(width: 16), + // Provider icon + Icon( + info.icon, + color: info.isBuiltIn + ? colorScheme.primary + : colorScheme.secondary, + ), + const SizedBox(width: 12), + // Provider name + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.name, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + info.isBuiltIn ? 'Built-in' : 'Extension', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Drag handle + Icon( + Icons.drag_handle, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } + + _ProviderInfo _getProviderInfo(String provider) { + switch (provider) { + case 'tidal': + return _ProviderInfo( + name: 'Tidal', + icon: Icons.music_note, + isBuiltIn: true, + ); + case 'qobuz': + return _ProviderInfo( + name: 'Qobuz', + icon: Icons.album, + isBuiltIn: true, + ); + case 'amazon': + return _ProviderInfo( + name: 'Amazon Music', + icon: Icons.shopping_bag, + isBuiltIn: true, + ); + default: + // Extension provider + return _ProviderInfo( + name: provider, + icon: Icons.extension, + isBuiltIn: false, + ); + } + } +} + +class _ProviderInfo { + final String name; + final IconData icon; + final bool isBuiltIn; + + _ProviderInfo({ + required this.name, + required this.icon, + required this.isBuiltIn, + }); +} diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 9c30f67d..a589b730 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart'; import 'package:spotiflac_android/screens/settings/download_settings_page.dart'; +import 'package:spotiflac_android/screens/settings/extensions_page.dart'; import 'package:spotiflac_android/screens/settings/options_settings_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart'; @@ -31,8 +32,11 @@ class SettingsTab extends ConsumerWidget { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); - + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + return FlexibleSpaceBar( expandedTitleScale: 1.0, titlePadding: const EdgeInsets.only(left: 24, bottom: 16), @@ -58,7 +62,8 @@ class SettingsTab extends ConsumerWidget { icon: Icons.palette_outlined, title: 'Appearance', subtitle: 'Theme, colors, display', - onTap: () => _navigateTo(context, const AppearanceSettingsPage()), + onTap: () => + _navigateTo(context, const AppearanceSettingsPage()), ), SettingsItem( icon: Icons.download_outlined, @@ -71,6 +76,12 @@ class SettingsTab extends ConsumerWidget { title: 'Options', subtitle: 'Fallback, lyrics, cover art, updates', onTap: () => _navigateTo(context, const OptionsSettingsPage()), + ), + SettingsItem( + icon: Icons.extension_outlined, + title: 'Extensions', + subtitle: 'Manage download providers', + onTap: () => _navigateTo(context, const ExtensionsPage()), showDivider: false, ), ], @@ -97,7 +108,7 @@ class SettingsTab extends ConsumerWidget { ], ), ), - + // Fill remaining space const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ], @@ -105,6 +116,27 @@ class SettingsTab extends ConsumerWidget { } void _navigateTo(BuildContext context, Widget page) { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + Navigator.of(context).push( + // Use PageRouteBuilder for better predictive back gesture support + // MaterialPageRoute can cause freeze on some devices with gesture navigation + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + // Use slide transition similar to MaterialPageRoute + const begin = Offset(1.0, 0.0); + const end = Offset.zero; + const curve = Curves.easeInOut; + var tween = Tween(begin: begin, end: end).chain( + CurveTween(curve: curve), + ); + return SlideTransition( + position: animation.drive(tween), + child: child, + ); + }, + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + ), + ); } } diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 2060360e..b4a8fd7e 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -444,11 +444,10 @@ class _SetupScreenState extends ConsumerState { _clientIdController.text.trim(), _clientSecretController.text.trim(), ); - ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true); - // Set search source to Spotify when using custom credentials + // Set search source to Spotify when credentials are provided ref.read(settingsProvider.notifier).setMetadataSource('spotify'); } else { - // Use Deezer as default search source + // Use Deezer as default search source (free, no credentials required) ref.read(settingsProvider.notifier).setMetadataSource('deezer'); } diff --git a/lib/screens/store/extension_details_screen.dart b/lib/screens/store/extension_details_screen.dart new file mode 100644 index 00000000..4ca5b7a3 --- /dev/null +++ b/lib/screens/store/extension_details_screen.dart @@ -0,0 +1,751 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/providers/store_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +class ExtensionDetailsScreen extends ConsumerStatefulWidget { + final StoreExtension extension; + + const ExtensionDetailsScreen({super.key, required this.extension}); + + @override + ConsumerState createState() => + _ExtensionDetailsScreenState(); +} + +class _ExtensionDetailsScreenState + extends ConsumerState { + + @override + Widget build(BuildContext context) { + // Watch store provider to get latest state of this extension (e.g. if updated/installed) + final storeState = ref.watch(storeProvider); + + // Find our extension in the store state to get the latest status + // If not found in current store state (rare), fallback to widget.extension + final liveExtension = + storeState.extensions + .where((e) => e.id == widget.extension.id) + .firstOrNull ?? + widget.extension; + + final isDownloading = storeState.downloadingId == liveExtension.id; + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: CustomScrollView( + slivers: [ + _buildAppBar(context, liveExtension, colorScheme), + _buildInfoCard(context, liveExtension, colorScheme, isDownloading), + _buildSectionHeader( + context, + 'About', + Icons.info_outline, + colorScheme, + ), + _buildDescription(context, liveExtension, colorScheme), + + if (liveExtension.tags.isNotEmpty) ...[ + _buildSectionHeader(context, 'Tags', Icons.tag, colorScheme), + _buildTags(context, liveExtension, colorScheme), + ], + + _buildSectionHeader( + context, + 'Information', + Icons.table_chart_outlined, + colorScheme, + ), + _buildMetadataTable(context, liveExtension, colorScheme), + + _buildSectionHeader( + context, + 'Capabilities', + Icons.extension_outlined, + colorScheme, + ), + _buildCapabilities(context, liveExtension, colorScheme), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Widget _buildAppBar( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverAppBar( + expandedHeight: 200, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + background: Center( + child: Padding( + padding: const EdgeInsets.only(top: kToolbarHeight), + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: colorScheme.surfaceContainerHighest, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: ext.iconUrl != null && ext.iconUrl!.isNotEmpty + ? Image.network( + ext.iconUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildFallbackIcon(ext, colorScheme, 50), + ) + : _buildFallbackIcon(ext, colorScheme, 50), + ), + ), + ), + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + ); + } + + Widget _buildFallbackIcon( + StoreExtension ext, + ColorScheme colorScheme, + double size, + ) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getCategoryIcon(ext.category), + size: size, + color: colorScheme.onSurfaceVariant, + ), + ); + } + + Widget _buildInfoCard( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + bool isDownloading, + ) { + 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: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ext.displayName, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + 'by ${ext.author}', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Badges row + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _Badge( + label: 'v${ext.version}', + color: colorScheme.secondaryContainer, + textColor: colorScheme.onSecondaryContainer, + ), + _Badge( + label: _getCategoryName(ext.category), + color: colorScheme.tertiaryContainer, + textColor: colorScheme.onTertiaryContainer, + ), + if (ext.isInstalled) + _Badge( + label: 'Installed', + color: colorScheme.primaryContainer, + textColor: colorScheme.onPrimaryContainer, + icon: Icons.check, + ), + ], + ), + + const SizedBox(height: 24), + + // Action Buttons + if (isDownloading) + Center( + child: CircularProgressIndicator( + color: colorScheme.primary, + ), + ) + else ...[ + if (ext.hasUpdate) + FilledButton.icon( + onPressed: () => _updateExtension(ext), + icon: const Icon(Icons.update), + label: Text('Update to v${ext.version}'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ) + else if (ext.isInstalled) + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: null, + icon: const Icon(Icons.check), + label: const Text('Installed'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + const SizedBox(width: 12), + IconButton.filled( + onPressed: () => _uninstallExtension(ext), + icon: const Icon(Icons.delete_outline), + style: IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + minimumSize: const Size(52, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + tooltip: 'Uninstall', + ), + ], + ) + else + FilledButton.icon( + onPressed: () => _installExtension(ext), + icon: const Icon(Icons.download), + label: const Text('Install Extension'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildSectionHeader( + BuildContext context, + String title, + IconData icon, + ColorScheme colorScheme, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + children: [ + Icon(icon, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDescription( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text( + ext.description, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.5, + color: colorScheme.onSurface, + ), + ), + ), + ); + } + + Widget _buildTags( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: ext.tags + .map( + (tag) => Chip( + label: Text(tag), + backgroundColor: colorScheme.surfaceContainer, + labelStyle: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ) + .toList(), + ), + ), + ); + } + + Widget _buildMetadataTable( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + sliver: SliverToBoxAdapter( + child: Card( + elevation: 0, + color: colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + _MetadataRow( + label: 'Updated', + value: ext.updatedAt.isNotEmpty + ? _formatDate(ext.updatedAt) + : '-', + colorScheme: colorScheme, + ), + _MetadataRow( + label: 'ID', + value: ext.id, + colorScheme: colorScheme, + ), + _MetadataRow( + label: 'Min App Version', + value: ext.minAppVersion ?? 'Any', + colorScheme: colorScheme, + isLast: true, + ), + ], + ), + ), + ), + ); + } + + Widget _buildCapabilities( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + // Determine capabilities based on category + final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration'; + final isDownloadProvider = ext.category == 'download'; + final isLyricsProvider = ext.category == 'lyrics'; + final isUtility = ext.category == 'utility'; + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + sliver: SliverToBoxAdapter( + child: Card( + elevation: 0, + color: colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + _CapabilityRow( + icon: Icons.search, + label: 'Metadata Provider', + enabled: isMetadataProvider, + colorScheme: colorScheme, + ), + _CapabilityRow( + icon: Icons.download, + label: 'Download Provider', + enabled: isDownloadProvider, + colorScheme: colorScheme, + ), + _CapabilityRow( + icon: Icons.lyrics, + label: 'Lyrics Provider', + enabled: isLyricsProvider, + colorScheme: colorScheme, + ), + _CapabilityRow( + icon: Icons.build, + label: 'Utility Functions', + enabled: isUtility, + colorScheme: colorScheme, + isLast: true, + ), + ], + ), + ), + ), + ); + } + + String _formatDate(String dateStr) { + try { + final date = DateTime.parse(dateStr); + final now = DateTime.now(); + final diff = now.difference(date); + + if (diff.inDays == 0) { + return 'Today'; + } else if (diff.inDays == 1) { + return 'Yesterday'; + } else if (diff.inDays < 7) { + return '${diff.inDays} days ago'; + } else if (diff.inDays < 30) { + return '${(diff.inDays / 7).floor()} weeks ago'; + } else if (diff.inDays < 365) { + return '${(diff.inDays / 30).floor()} months ago'; + } else { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + } catch (_) { + return dateStr.split('T').first; + } + } + + IconData _getCategoryIcon(String category) { + switch (category) { + case 'metadata': + return Icons.label_outline; + case 'download': + return Icons.download_outlined; + case 'utility': + return Icons.build_outlined; + case 'lyrics': + return Icons.lyrics_outlined; + case 'integration': + return Icons.link; + default: + return Icons.extension; + } + } + + String _getCategoryName(String category) { + switch (category) { + case 'metadata': + return 'Metadata'; + case 'download': + return 'Download'; + case 'utility': + return 'Utility'; + case 'lyrics': + return 'Lyrics'; + case 'integration': + return 'Integration'; + default: + return category; + } + } + + Future _installExtension(StoreExtension ext) async { + final tempDir = await getTemporaryDirectory(); + final appDir = await getApplicationDocumentsDirectory(); + final extensionsDir = '${appDir.path}/extensions'; + + final success = await ref + .read(storeProvider.notifier) + .installExtension(ext.id, tempDir.path, extensionsDir); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? '${ext.displayName} installed.' + : 'Failed to install ${ext.displayName}', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _updateExtension(StoreExtension ext) async { + final tempDir = await getTemporaryDirectory(); + + final success = await ref + .read(storeProvider.notifier) + .updateExtension(ext.id, tempDir.path); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? '${ext.displayName} updated.' + : 'Failed to update ${ext.displayName}', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _uninstallExtension(StoreExtension ext) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Uninstall Extension?'), + content: Text('Are you sure you want to remove ${ext.displayName}?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + 'Uninstall', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + + if (confirm == true) { + await ref.read(extensionProvider.notifier).removeExtension(ext.id); + await ref.read(storeProvider.notifier).refresh(); + if (mounted) { + Navigator.pop(context); + } + } + } +} + +class _Badge extends StatelessWidget { + final String label; + final Color color; + final Color textColor; + final IconData? icon; + + const _Badge({ + required this.label, + required this.color, + required this.textColor, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: textColor), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ], + ), + ); + } +} + +class _MetadataRow extends StatelessWidget { + final String label; + final String value; + final ColorScheme colorScheme; + final bool isLast; + + const _MetadataRow({ + required this.label, + required this.value, + required this.colorScheme, + this.isLast = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (!isLast) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + indent: 16, + endIndent: 16, + ), + ], + ); + } +} + +class _CapabilityRow extends StatelessWidget { + final IconData icon; + final String label; + final bool enabled; + final ColorScheme colorScheme; + final bool isLast; + + const _CapabilityRow({ + required this.icon, + required this.label, + required this.enabled, + required this.colorScheme, + this.isLast = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 14, + ), + ), + ), + Icon( + enabled ? Icons.check_circle : Icons.cancel_outlined, + size: 20, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + ], + ), + ), + if (!isLast) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + indent: 16, + endIndent: 16, + ), + ], + ); + } +} diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart new file mode 100644 index 00000000..7d8e956a --- /dev/null +++ b/lib/screens/store_tab.dart @@ -0,0 +1,622 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/providers/store_provider.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/screens/store/extension_details_screen.dart'; + +class StoreTab extends ConsumerStatefulWidget { + const StoreTab({super.key}); + + @override + ConsumerState createState() => _StoreTabState(); +} + +class _StoreTabState extends ConsumerState { + final _searchController = TextEditingController(); + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _initialize()); + } + + Future _initialize() async { + if (_isInitialized) return; + _isInitialized = true; + + final cacheDir = await getApplicationCacheDirectory(); + + // Check if widget is still mounted after async operation + if (!mounted) return; + + await ref.read(storeProvider.notifier).initialize(cacheDir.path); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(storeProvider); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return Scaffold( + body: RefreshIndicator( + onRefresh: () => + ref.read(storeProvider.notifier).refresh(forceRefresh: true), + child: CustomScrollView( + slivers: [ + // App Bar - consistent with other tabs + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + 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); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: const EdgeInsets.only(left: 24, bottom: 16), + title: Text( + 'Store', + style: TextStyle( + fontSize: 20 + (14 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // Search Bar + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search extensions...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + ref + .read(storeProvider.notifier) + .setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + ref.read(storeProvider.notifier).setSearchQuery(value); + setState(() {}); // Update suffix icon + }, + ), + ), + ), + + // Category Chips + SliverToBoxAdapter( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + _CategoryChip( + label: 'All', + icon: Icons.apps, + isSelected: state.selectedCategory == null, + onTap: () => + ref.read(storeProvider.notifier).setCategory(null), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Metadata', + icon: Icons.label_outline, + isSelected: + state.selectedCategory == StoreCategory.metadata, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.metadata), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Download', + icon: Icons.download_outlined, + isSelected: + state.selectedCategory == StoreCategory.download, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.download), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Utility', + icon: Icons.build_outlined, + isSelected: + state.selectedCategory == StoreCategory.utility, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.utility), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Lyrics', + icon: Icons.lyrics_outlined, + isSelected: + state.selectedCategory == StoreCategory.lyrics, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.lyrics), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Integration', + icon: Icons.link, + isSelected: + state.selectedCategory == StoreCategory.integration, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.integration), + ), + ], + ), + ), + ), + + // Content + if (state.isLoading && state.extensions.isEmpty) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (state.error != null && state.extensions.isEmpty) + SliverFillRemaining( + child: _buildErrorState(state.error!, colorScheme), + ) + else if (state.filteredExtensions.isEmpty) + SliverFillRemaining(child: _buildEmptyState(state, colorScheme)) + else ...[ + // Extensions count + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + '${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + // Extensions list in grouped card (like queue_tab) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SettingsGroup( + children: state.filteredExtensions.asMap().entries.map(( + entry, + ) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: + index < state.filteredExtensions.length - 1, + isDownloading: state.downloadingId == ext.id, + onInstall: () => _installExtension(ext), + onUpdate: () => _updateExtension(ext), + onTap: () => _showExtensionDetails(ext), + ); + }).toList(), + ), + ), + ), + + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ], + ), + ), + ); + } + + Widget _buildErrorState(String error, ColorScheme colorScheme) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 64, color: colorScheme.error), + const SizedBox(height: 16), + Text( + 'Failed to load store', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + error, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () => + ref.read(storeProvider.notifier).refresh(forceRefresh: true), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) { + final hasFilters = + state.searchQuery.isNotEmpty || state.selectedCategory != null; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasFilters ? Icons.search_off : Icons.extension_off, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + hasFilters ? 'No extensions found' : 'No extensions available', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (hasFilters) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: () { + _searchController.clear(); + ref.read(storeProvider.notifier).clearSearch(); + }, + child: const Text('Clear filters'), + ), + ], + ], + ), + ); + } + + void _showExtensionDetails(StoreExtension ext) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ExtensionDetailsScreen(extension: ext), + ), + ); + } + + Future _installExtension(StoreExtension ext) async { + final tempDir = await getTemporaryDirectory(); + final appDir = await getApplicationDocumentsDirectory(); + final extensionsDir = '${appDir.path}/extensions'; + + final success = await ref + .read(storeProvider.notifier) + .installExtension(ext.id, tempDir.path, extensionsDir); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? '${ext.displayName} installed. Enable it in Settings > Extensions' + : 'Failed to install ${ext.displayName}', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _updateExtension(StoreExtension ext) async { + final tempDir = await getTemporaryDirectory(); + + final success = await ref + .read(storeProvider.notifier) + .updateExtension(ext.id, tempDir.path); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? '${ext.displayName} updated to v${ext.version}' + : 'Failed to update ${ext.displayName}', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } +} + +class _CategoryChip extends StatelessWidget { + final String label; + final IconData icon; + final bool isSelected; + final VoidCallback onTap; + + const _CategoryChip({ + required this.label, + required this.icon, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(label)], + ), + selected: isSelected, + onSelected: (_) => onTap(), + showCheckmark: false, + ); + } +} + +class _ExtensionItem extends StatelessWidget { + final StoreExtension extension; + final bool showDivider; + final bool isDownloading; + final VoidCallback onInstall; + final VoidCallback onUpdate; + final VoidCallback? onTap; + + const _ExtensionItem({ + required this.extension, + required this.showDivider, + required this.isDownloading, + required this.onInstall, + required this.onUpdate, + this.onTap, + }); + + IconData _getCategoryIcon(String category) { + switch (category) { + case StoreCategory.metadata: + return Icons.label_outline; + case StoreCategory.download: + return Icons.download_outlined; + case StoreCategory.utility: + return Icons.build_outlined; + case StoreCategory.lyrics: + return Icons.lyrics_outlined; + case StoreCategory.integration: + return Icons.link; + default: + return Icons.extension; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Extension icon - custom or category-based + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: extension.isInstalled + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: + extension.iconUrl != null && extension.iconUrl!.isNotEmpty + ? Image.network( + extension.iconUrl!, + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + _getCategoryIcon(extension.category), + color: extension.isInstalled + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + value: + loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + ) + : Icon( + _getCategoryIcon(extension.category), + color: extension.isInstalled + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 16), + // Extension info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + extension.displayName, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w500), + ), + ), + // Version badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'v${extension.version}', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + 'by ${extension.author}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + extension.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + // Action button + if (isDownloading) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (extension.hasUpdate) + FilledButton.tonal( + onPressed: onUpdate, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + ), + child: const Text('Update'), + ) + else if (extension.isInstalled) + OutlinedButton( + onPressed: null, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check, size: 16, color: colorScheme.outline), + const SizedBox(width: 4), + Text( + 'Installed', + style: TextStyle(color: colorScheme.outline), + ), + ], + ), + ) + else + FilledButton( + onPressed: onInstall, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + ), + child: const Text('Install'), + ), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 76, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index fec533b8..72e52492 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -34,7 +34,13 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _checkFile() async { - final file = File(widget.item.filePath); + // Strip EXISTS: prefix from legacy history items + var filePath = widget.item.filePath; + if (filePath.startsWith('EXISTS:')) { + filePath = filePath.substring(7); + } + + final file = File(filePath); final exists = await file.exists(); int? size; @@ -67,6 +73,12 @@ class _TrackMetadataScreenState extends ConsumerState { int? get discNumber => item.discNumber; String? get releaseDate => item.releaseDate; String? get isrc => item.isrc; + + // Clean filePath - strip EXISTS: prefix from legacy history items + String get cleanFilePath { + final path = item.filePath; + return path.startsWith('EXISTS:') ? path.substring(7) : path; + } int? get bitDepth => item.bitDepth; int? get sampleRate => item.sampleRate; @@ -515,7 +527,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) { - final fileName = item.filePath.split(Platform.pathSeparator).last; + final fileName = cleanFilePath.split(Platform.pathSeparator).last; final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; return Card( @@ -631,7 +643,7 @@ class _TrackMetadataScreenState extends ConsumerState { // File path InkWell( - onTap: () => _copyToClipboard(context, item.filePath), + onTap: () => _copyToClipboard(context, cleanFilePath), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(12), @@ -643,7 +655,7 @@ class _TrackMetadataScreenState extends ConsumerState { children: [ Expanded( child: Text( - item.filePath, + cleanFilePath, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', color: colorScheme.onSurfaceVariant, @@ -776,7 +788,7 @@ class _TrackMetadataScreenState extends ConsumerState { item.spotifyId ?? '', item.trackName, item.artistName, - filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first + filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first ).timeout( const Duration(seconds: 20), onTimeout: () => '', // Return empty string on timeout @@ -833,7 +845,7 @@ class _TrackMetadataScreenState extends ConsumerState { Expanded( flex: 2, child: FilledButton.icon( - onPressed: fileExists ? () => _openFile(context, item.filePath) : null, + onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null, icon: const Icon(Icons.play_arrow), label: const Text('Play'), style: FilledButton.styleFrom( @@ -890,7 +902,7 @@ class _TrackMetadataScreenState extends ConsumerState { title: const Text('Copy file path'), onTap: () { Navigator.pop(context); - _copyToClipboard(context, item.filePath); + _copyToClipboard(context, cleanFilePath); }, ), ListTile( @@ -933,7 +945,7 @@ class _TrackMetadataScreenState extends ConsumerState { onPressed: () async { // Delete the file first try { - final file = File(item.filePath); + final file = File(cleanFilePath); if (await file.exists()) { await file.delete(); } @@ -984,7 +996,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _shareFile(BuildContext context) async { - final file = File(item.filePath); + final file = File(cleanFilePath); if (!await file.exists()) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -996,7 +1008,7 @@ class _TrackMetadataScreenState extends ConsumerState { await SharePlus.instance.share( ShareParams( - files: [XFile(item.filePath)], + files: [XFile(cleanFilePath)], text: '${item.trackName} - ${item.artistName}', ), ); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index fb4fb284..43a21fb5 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -331,7 +331,6 @@ class PlatformBridge { } /// Set custom Spotify API credentials - /// Pass empty strings to use default credentials static Future setSpotifyCredentials(String clientId, String clientSecret) async { await _channel.invokeMethod('setSpotifyCredentials', { 'client_id': clientId, @@ -339,6 +338,13 @@ class PlatformBridge { }); } + /// Check if Spotify credentials are configured + /// Returns true if credentials are available (custom or env vars) + static Future hasSpotifyCredentials() async { + final result = await _channel.invokeMethod('hasSpotifyCredentials'); + return result as bool; + } + /// Pre-warm track ID cache for album/playlist tracks /// This runs in background and returns immediately /// Speeds up subsequent downloads by caching ISRC → Track ID mappings @@ -439,4 +445,418 @@ class PlatformBridge { static Future setGoLoggingEnabled(bool enabled) async { await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); } + + // ==================== EXTENSION SYSTEM ==================== + + /// Initialize the extension system + static Future initExtensionSystem(String extensionsDir, String dataDir) async { + _log.d('initExtensionSystem: $extensionsDir, $dataDir'); + await _channel.invokeMethod('initExtensionSystem', { + 'extensions_dir': extensionsDir, + 'data_dir': dataDir, + }); + } + + /// Load all extensions from directory + static Future> loadExtensionsFromDir(String dirPath) async { + _log.d('loadExtensionsFromDir: $dirPath'); + final result = await _channel.invokeMethod('loadExtensionsFromDir', { + 'dir_path': dirPath, + }); + return jsonDecode(result as String) as Map; + } + + /// Load a single extension from file + static Future> loadExtensionFromPath(String filePath) async { + _log.d('loadExtensionFromPath: $filePath'); + final result = await _channel.invokeMethod('loadExtensionFromPath', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + /// Unload an extension + static Future unloadExtension(String extensionId) async { + _log.d('unloadExtension: $extensionId'); + await _channel.invokeMethod('unloadExtension', { + 'extension_id': extensionId, + }); + } + + /// Remove an extension completely (unload + delete files) + static Future removeExtension(String extensionId) async { + _log.d('removeExtension: $extensionId'); + await _channel.invokeMethod('removeExtension', { + 'extension_id': extensionId, + }); + } + + /// Upgrade an existing extension from a new package file + static Future> upgradeExtension(String filePath) async { + _log.d('upgradeExtension: $filePath'); + final result = await _channel.invokeMethod('upgradeExtension', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + /// Check if a package file is an upgrade for an existing extension + static Future> checkExtensionUpgrade(String filePath) async { + _log.d('checkExtensionUpgrade: $filePath'); + final result = await _channel.invokeMethod('checkExtensionUpgrade', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + /// Get all installed extensions + static Future>> getInstalledExtensions() async { + final result = await _channel.invokeMethod('getInstalledExtensions'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Enable or disable an extension + static Future setExtensionEnabled(String extensionId, bool enabled) async { + _log.d('setExtensionEnabled: $extensionId = $enabled'); + await _channel.invokeMethod('setExtensionEnabled', { + 'extension_id': extensionId, + 'enabled': enabled, + }); + } + + /// Set provider priority order + static Future setProviderPriority(List providerIds) async { + _log.d('setProviderPriority: $providerIds'); + await _channel.invokeMethod('setProviderPriority', { + 'priority': jsonEncode(providerIds), + }); + } + + /// Get provider priority order + static Future> getProviderPriority() async { + final result = await _channel.invokeMethod('getProviderPriority'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as String).toList(); + } + + /// Set metadata provider priority order + static Future setMetadataProviderPriority(List providerIds) async { + _log.d('setMetadataProviderPriority: $providerIds'); + await _channel.invokeMethod('setMetadataProviderPriority', { + 'priority': jsonEncode(providerIds), + }); + } + + /// Get metadata provider priority order + static Future> getMetadataProviderPriority() async { + final result = await _channel.invokeMethod('getMetadataProviderPriority'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as String).toList(); + } + + /// Get extension settings + static Future> getExtensionSettings(String extensionId) async { + final result = await _channel.invokeMethod('getExtensionSettings', { + 'extension_id': extensionId, + }); + return jsonDecode(result as String) as Map; + } + + /// Set extension settings + static Future setExtensionSettings(String extensionId, Map settings) async { + _log.d('setExtensionSettings: $extensionId'); + await _channel.invokeMethod('setExtensionSettings', { + 'extension_id': extensionId, + 'settings': jsonEncode(settings), + }); + } + + /// Search tracks using extension providers + static Future>> searchTracksWithExtensions(String query, {int limit = 20}) async { + _log.d('searchTracksWithExtensions: "$query"'); + final result = await _channel.invokeMethod('searchTracksWithExtensions', { + 'query': query, + 'limit': limit, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Download with extension providers (includes fallback) + static Future> downloadWithExtensions({ + required String isrc, + required String spotifyId, + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + String quality = 'LOSSLESS', + bool embedLyrics = true, + bool embedMaxQualityCover = true, + int trackNumber = 1, + int discNumber = 1, + int totalTracks = 1, + String? releaseDate, + String? itemId, + int durationMs = 0, + String? source, // Extension ID that provided this track (prioritize this extension) + }) async { + _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); + final request = jsonEncode({ + 'isrc': isrc, + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + 'album_name': albumName, + 'album_artist': albumArtist ?? artistName, + 'cover_url': coverUrl, + 'output_dir': outputDir, + 'filename_format': filenameFormat, + 'quality': quality, + 'embed_lyrics': embedLyrics, + 'embed_max_quality_cover': embedMaxQualityCover, + 'track_number': trackNumber, + 'disc_number': discNumber, + 'total_tracks': totalTracks, + 'release_date': releaseDate ?? '', + 'item_id': itemId ?? '', + 'duration_ms': durationMs, + 'source': source ?? '', // Extension ID that provided this track + }); + + final result = await _channel.invokeMethod('downloadWithExtensions', request); + return jsonDecode(result as String) as Map; + } + + /// Cleanup all extensions (call on app close) + static Future cleanupExtensions() async { + _log.d('cleanupExtensions'); + await _channel.invokeMethod('cleanupExtensions'); + } + + // ==================== EXTENSION AUTH API ==================== + + /// Get pending auth request for an extension (if any) + static Future?> getExtensionPendingAuth(String extensionId) async { + final result = await _channel.invokeMethod('getExtensionPendingAuth', { + 'extension_id': extensionId, + }); + if (result == null) return null; + return jsonDecode(result as String) as Map; + } + + /// Set auth code for an extension (after OAuth callback) + static Future setExtensionAuthCode(String extensionId, String authCode) async { + _log.d('setExtensionAuthCode: $extensionId'); + await _channel.invokeMethod('setExtensionAuthCode', { + 'extension_id': extensionId, + 'auth_code': authCode, + }); + } + + /// Set tokens for an extension (after token exchange) + static Future setExtensionTokens( + String extensionId, { + required String accessToken, + String? refreshToken, + int? expiresIn, + }) async { + _log.d('setExtensionTokens: $extensionId'); + await _channel.invokeMethod('setExtensionTokens', { + 'extension_id': extensionId, + 'access_token': accessToken, + 'refresh_token': refreshToken ?? '', + 'expires_in': expiresIn ?? 0, + }); + } + + /// Clear pending auth request for an extension + static Future clearExtensionPendingAuth(String extensionId) async { + await _channel.invokeMethod('clearExtensionPendingAuth', { + 'extension_id': extensionId, + }); + } + + /// Check if extension is authenticated + static Future isExtensionAuthenticated(String extensionId) async { + final result = await _channel.invokeMethod('isExtensionAuthenticated', { + 'extension_id': extensionId, + }); + return result as bool; + } + + /// Get all pending auth requests (for polling) + static Future>> getAllPendingAuthRequests() async { + final result = await _channel.invokeMethod('getAllPendingAuthRequests'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + // ==================== EXTENSION FFMPEG API ==================== + + /// Get pending FFmpeg command for execution + static Future?> getPendingFFmpegCommand(String commandId) async { + final result = await _channel.invokeMethod('getPendingFFmpegCommand', { + 'command_id': commandId, + }); + if (result == null) return null; + return jsonDecode(result as String) as Map; + } + + /// Set FFmpeg command result + static Future setFFmpegCommandResult( + String commandId, { + required bool success, + String output = '', + String error = '', + }) async { + await _channel.invokeMethod('setFFmpegCommandResult', { + 'command_id': commandId, + 'success': success, + 'output': output, + 'error': error, + }); + } + + /// Get all pending FFmpeg commands + static Future>> getAllPendingFFmpegCommands() async { + final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + // ==================== EXTENSION CUSTOM SEARCH ==================== + + /// Perform custom search using an extension + static Future>> customSearchWithExtension( + String extensionId, + String query, { + Map? options, + }) async { + final result = await _channel.invokeMethod('customSearchWithExtension', { + 'extension_id': extensionId, + 'query': query, + 'options': options != null ? jsonEncode(options) : '', + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Get all extensions that provide custom search + static Future>> getSearchProviders() async { + final result = await _channel.invokeMethod('getSearchProviders'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + // ==================== EXTENSION URL HANDLER ==================== + + /// Handle a URL with any matching extension + /// Returns null if no extension can handle the URL + static Future?> handleURLWithExtension(String url) async { + try { + final result = await _channel.invokeMethod('handleURLWithExtension', { + 'url': url, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + // No extension found or error handling URL + return null; + } + } + + /// Find an extension that can handle the given URL + /// Returns extension ID or null if none found + static Future findURLHandler(String url) async { + final result = await _channel.invokeMethod('findURLHandler', { + 'url': url, + }); + if (result == null || result == '') return null; + return result as String; + } + + /// Get all extensions that handle custom URLs + static Future>> getURLHandlers() async { + final result = await _channel.invokeMethod('getURLHandlers'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + // ==================== EXTENSION POST-PROCESSING ==================== + + /// Run post-processing hooks on a file + static Future> runPostProcessing( + String filePath, { + Map? metadata, + }) async { + final result = await _channel.invokeMethod('runPostProcessing', { + 'file_path': filePath, + 'metadata': metadata != null ? jsonEncode(metadata) : '', + }); + return jsonDecode(result as String) as Map; + } + + /// Get all extensions that provide post-processing + static Future>> getPostProcessingProviders() async { + final result = await _channel.invokeMethod('getPostProcessingProviders'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + // ==================== EXTENSION STORE ==================== + + /// Initialize extension store + static Future initExtensionStore(String cacheDir) async { + _log.d('initExtensionStore: $cacheDir'); + await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); + } + + /// Get all extensions from store with installation status + static Future>> getStoreExtensions({bool forceRefresh = false}) async { + _log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); + final result = await _channel.invokeMethod('getStoreExtensions', { + 'force_refresh': forceRefresh, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Search extensions in store + static Future>> searchStoreExtensions(String query, {String? category}) async { + _log.d('searchStoreExtensions: "$query" (category: $category)'); + final result = await _channel.invokeMethod('searchStoreExtensions', { + 'query': query, + 'category': category ?? '', + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Get store categories + static Future> getStoreCategories() async { + final result = await _channel.invokeMethod('getStoreCategories'); + final list = jsonDecode(result as String) as List; + return list.cast(); + } + + /// Download extension from store + static Future downloadStoreExtension(String extensionId, String destDir) async { + _log.i('downloadStoreExtension: $extensionId to $destDir'); + final result = await _channel.invokeMethod('downloadStoreExtension', { + 'extension_id': extensionId, + 'dest_dir': destDir, + }); + return result as String; + } + + /// Clear store cache + static Future clearStoreCache() async { + _log.d('clearStoreCache'); + await _channel.invokeMethod('clearStoreCache'); + } } diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart new file mode 100644 index 00000000..a5f9a38b --- /dev/null +++ b/lib/widgets/download_service_picker.dart @@ -0,0 +1,483 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; + +/// Built-in service info with quality options +class BuiltInService { + final String id; + final String label; + final List qualityOptions; + + const BuiltInService({ + required this.id, + required this.label, + required this.qualityOptions, + }); +} + +/// Default quality options for built-in services (Tidal, Qobuz, Amazon) +const _builtInServices = [ + BuiltInService( + id: 'tidal', + label: 'Tidal', + qualityOptions: [ + QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), + QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), + QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + ], + ), + BuiltInService( + id: 'qobuz', + label: 'Qobuz', + qualityOptions: [ + QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), + QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), + QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + ], + ), + BuiltInService( + id: 'amazon', + label: 'Amazon', + qualityOptions: [ + QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), + QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), + QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + ], + ), +]; + +/// A reusable widget for selecting download service (built-in + extensions) +class DownloadServicePicker extends ConsumerStatefulWidget { + final String? trackName; + final String? artistName; + final String? coverUrl; + final void Function(String quality, String service) onSelect; + + const DownloadServicePicker({ + super.key, + this.trackName, + this.artistName, + this.coverUrl, + required this.onSelect, + }); + + @override + ConsumerState createState() => _DownloadServicePickerState(); + + /// Show the download service picker as a modal bottom sheet + static void show( + BuildContext context, { + String? trackName, + String? artistName, + String? coverUrl, + required void Function(String quality, String service) onSelect, + }) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + isScrollControlled: true, + builder: (context) => DownloadServicePicker( + trackName: trackName, + artistName: artistName, + coverUrl: coverUrl, + onSelect: onSelect, + ), + ); + } +} + +class _DownloadServicePickerState extends ConsumerState { + late String _selectedService; + + @override + void initState() { + super.initState(); + _selectedService = ref.read(settingsProvider).defaultService; + } + + /// Get quality options for the selected service + List _getQualityOptions() { + // Check if it's a built-in service + final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull; + if (builtIn != null) { + return builtIn.qualityOptions; + } + + // Check if it's an extension + final extensionState = ref.read(extensionProvider); + final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull; + if (ext != null && ext.qualityOptions.isNotEmpty) { + return ext.qualityOptions; + } + + // Default quality options if extension doesn't specify any + return const [ + QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), + ]; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final extensionState = ref.watch(extensionProvider); + + // Get enabled download provider extensions + final downloadExtensions = extensionState.extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .toList(); + + final qualityOptions = _getQualityOptions(); + + return SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Track info header (if provided) + if (widget.trackName != null) ...[ + _TrackInfoHeader( + trackName: widget.trackName!, + artistName: widget.artistName, + coverUrl: widget.coverUrl, + ), + Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ] else ...[ + const SizedBox(height: 8), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ], + + // Service selector section + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text( + 'Download From', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + // Built-in services + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // Built-in services + for (final service in _builtInServices) + _ServiceChip( + label: service.label, + isSelected: _selectedService == service.id, + onTap: () => setState(() => _selectedService = service.id), + ), + // Extension services + for (final ext in downloadExtensions) + _ServiceChip( + label: ext.displayName, + isSelected: _selectedService == ext.id, + onTap: () => setState(() => _selectedService = ext.id), + iconPath: ext.iconPath, + ), + ], + ), + ), + + // Quality selector section + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text( + 'Select Quality', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + // Disclaimer for built-in services + if (_builtInServices.any((s) => s.id == _selectedService)) + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + + // Quality options + for (final quality in qualityOptions) + _QualityOption( + title: quality.label, + subtitle: quality.description ?? '', + icon: _getQualityIcon(quality.id), + onTap: () { + Navigator.pop(context); + widget.onSelect(quality.id, _selectedService); + }, + ), + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + IconData _getQualityIcon(String qualityId) { + switch (qualityId.toUpperCase()) { + case 'HI_RES_LOSSLESS': + return Icons.four_k; + case 'HI_RES': + return Icons.high_quality; + case 'LOSSLESS': + return Icons.music_note; + case 'MP3_320': + case 'MP3': + return Icons.audiotrack; + case 'OPUS': + case 'OPUS_128': + return Icons.graphic_eq; + default: + return Icons.music_note; + } + } +} + + +class _QualityOption extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final VoidCallback onTap; + + const _QualityOption({ + required this.title, + required this.subtitle, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: subtitle.isNotEmpty + ? Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)) + : null, + onTap: onTap, + ); + } +} + +class _ServiceChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + final String? iconPath; + + const _ServiceChip({ + required this.label, + required this.isSelected, + required this.onTap, + this.iconPath, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconPath != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(iconPath!), + width: 18, + height: 18, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.extension, + size: 18, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 6), + ], + Text( + label, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _TrackInfoHeader extends StatefulWidget { + final String trackName; + final String? artistName; + final String? coverUrl; + + const _TrackInfoHeader({ + required this.trackName, + this.artistName, + this.coverUrl, + }); + + @override + State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); +} + +class _TrackInfoHeaderState extends State<_TrackInfoHeader> { + bool _expanded = false; + bool _isOverflowing = false; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + child: Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: widget.coverUrl != null + ? Image.network( + widget.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(width: 12), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); + final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); + final titlePainter = TextPainter( + text: titleSpan, + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(maxWidth: constraints.maxWidth); + final titleOverflows = titlePainter.didExceedMaxLines; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _isOverflowing != titleOverflows) { + setState(() => _isOverflowing = titleOverflows); + } + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.trackName, + style: titleStyle, + maxLines: _expanded ? 10 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + if (widget.artistName != null) ...[ + const SizedBox(height: 2), + Text( + widget.artistName!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: _expanded ? 3 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + ], + ], + ); + }, + ), + ), + if (_isOverflowing || _expanded) + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index 31706889..9d7eafb5 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -72,7 +72,7 @@ class SettingsItem extends StatelessWidget { InkWell( onTap: onTap, splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), + highlightColor: Colors.transparent, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( @@ -133,6 +133,7 @@ class SettingsSwitchItem extends StatelessWidget { final bool value; final ValueChanged? onChanged; final bool showDivider; + final bool enabled; const SettingsSwitchItem({ super.key, @@ -142,53 +143,60 @@ class SettingsSwitchItem extends StatelessWidget { required this.value, this.onChanged, this.showDivider = true, + this.enabled = true, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final isDisabled = !enabled || onChanged == null; return Column( mainAxisSize: MainAxisSize.min, children: [ - InkWell( - onTap: onChanged != null ? () => onChanged!(!value) : null, - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - if (icon != null) ...[ - Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), - const SizedBox(width: 16), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyLarge, - ), - if (subtitle != null) ...[ - const SizedBox(height: 2), + Opacity( + opacity: isDisabled ? 0.5 : 1.0, + child: InkWell( + onTap: isDisabled ? null : () => onChanged!(!value), + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, size: 24), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - subtitle!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + title, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: isDisabled ? colorScheme.outline : null, ), ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, + ), + ), + ], ], - ], + ), ), - ), - const SizedBox(width: 8), - Switch( - value: value, - onChanged: onChanged, - ), - ], + const SizedBox(width: 8), + Switch( + value: value, + onChanged: isDisabled ? null : onChanged, + ), + ], + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 75d62c55..1c4d0a3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.9+51 +version: 3.0.0+57 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 847080c9..99859a87 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.9+51 +version: 3.0.0+57 environment: sdk: ^3.10.0