mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 04:08:02 +02:00
Compare commits
10 Commits
v2.2.7
...
v3.0.0-alpha.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 35532b0c73 | |||
| 4c09b988e4 | |||
| c673581c32 | |||
| bcd718b178 | |||
| 2b9357cb6d | |||
| 26d84041c7 | |||
| 93b4047143 | |||
| a6d488696b | |||
| 3dbd131e49 | |||
| 57cb575483 |
@@ -16,7 +16,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I have searched existing issues and this bug hasn't been reported yet
|
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ contact_links:
|
|||||||
- name: README
|
- name: README
|
||||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
about: Check the README for setup instructions and FAQ
|
about: Check the README for setup instructions and FAQ
|
||||||
|
- name: Extension Development Guide
|
||||||
|
url: https://zarz.moe/docs
|
||||||
|
about: Documentation for building SpotiFLAC extensions
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||||
required: true
|
required: true
|
||||||
- label: I am using the latest version of SpotiFLAC
|
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
name: Extension API Feature Request
|
||||||
|
description: Request new API features or capabilities for extension development
|
||||||
|
title: "[Extension API]: "
|
||||||
|
labels: ["enhancement", "extension-api"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for helping improve the SpotiFLAC Extension API!
|
||||||
|
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extension_goal
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to build?
|
||||||
|
description: Describe the extension or feature you're developing
|
||||||
|
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current_limitation
|
||||||
|
attributes:
|
||||||
|
label: Current API Limitation
|
||||||
|
description: What's missing or limiting in the current extension API?
|
||||||
|
placeholder: |
|
||||||
|
The current API doesn't support:
|
||||||
|
- [missing feature 1]
|
||||||
|
- [missing feature 2]
|
||||||
|
|
||||||
|
This prevents me from...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposed_api
|
||||||
|
attributes:
|
||||||
|
label: Proposed API / Feature
|
||||||
|
description: Describe the API or feature you'd like to see added
|
||||||
|
placeholder: |
|
||||||
|
I would like to have:
|
||||||
|
- A new function `api.newFeature()` that does X
|
||||||
|
- A new manifest field `newOption` that enables Y
|
||||||
|
- Access to Z capability...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: use_case
|
||||||
|
attributes:
|
||||||
|
label: Use Case Example
|
||||||
|
description: Provide a code example of how you would use this feature
|
||||||
|
placeholder: |
|
||||||
|
```javascript
|
||||||
|
// Example usage in extension code
|
||||||
|
function download(request, progressCallback) {
|
||||||
|
const result = api.proposedFeature(params);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: api_category
|
||||||
|
attributes:
|
||||||
|
label: API Category
|
||||||
|
description: What category does this feature fall under?
|
||||||
|
options:
|
||||||
|
- HTTP/Network API
|
||||||
|
- File System API
|
||||||
|
- Storage API
|
||||||
|
- FFmpeg/Audio Processing
|
||||||
|
- Manifest Options
|
||||||
|
- Runtime Functions
|
||||||
|
- UI Integration
|
||||||
|
- Authentication
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: How critical is this for your extension?
|
||||||
|
options:
|
||||||
|
- Blocker - Cannot build my extension without this
|
||||||
|
- High - Major functionality depends on this
|
||||||
|
- Medium - Would significantly improve my extension
|
||||||
|
- Low - Nice to have
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: workaround
|
||||||
|
attributes:
|
||||||
|
label: Current Workaround
|
||||||
|
description: Are you using any workaround currently? If so, describe it.
|
||||||
|
placeholder: "Currently I'm working around this by..."
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, links to similar APIs, or examples from other platforms
|
||||||
|
placeholder: "Similar feature in other platforms: ..."
|
||||||
@@ -13,6 +13,9 @@ Thumbs.db
|
|||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
|
# Documentation (development only, published separately)
|
||||||
|
docs/
|
||||||
|
|
||||||
# Old spotiflac_android folder (moved to root)
|
# Old spotiflac_android folder (moved to root)
|
||||||
spotiflac_android/
|
spotiflac_android/
|
||||||
|
|
||||||
|
|||||||
+164
@@ -1,5 +1,169 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
|
||||||
|
- Added thumbnail ratio customization section
|
||||||
|
- Added extension upgrade documentation
|
||||||
|
- Added settings fields table with `secret` field
|
||||||
|
- Added new troubleshooting entries
|
||||||
|
- Updated table of contents
|
||||||
|
- Updated changelog
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.8] - 2026-01-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode
|
||||||
|
- Select multiple tracks at once
|
||||||
|
- "Select All" and "Delete Selected" actions
|
||||||
|
- Modern Material 3 bottom action bar (slides up from bottom)
|
||||||
|
- Works in both grid and list view modes
|
||||||
|
- **History Filter Tabs**: Filter history by All/Albums/Singles
|
||||||
|
- Album = tracks where album has >1 track in history
|
||||||
|
- Single = tracks where album has only 1 track in history
|
||||||
|
- Filter chips show counts for each category
|
||||||
|
- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album
|
||||||
|
- Album cards displayed in 2-column grid with cover art and track count badge
|
||||||
|
- Tap album to open dedicated album detail screen
|
||||||
|
- Album detail shows all downloaded tracks from that album
|
||||||
|
- Multi-select delete support within album view
|
||||||
|
- Auto-navigates back when album has <2 tracks remaining
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.2.7] - 2026-01-11
|
## [2.2.7] - 2026-01-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -40,6 +40,31 @@ To use Spotify as your search source without hitting rate limits:
|
|||||||
4. Enter your Client ID and Secret
|
4. Enter your Client ID and Secret
|
||||||
5. Change **Search Source** to Spotify
|
5. Change **Search Source** to Spotify
|
||||||
|
|
||||||
|
## Extensions (Alpha)
|
||||||
|
|
||||||
|
> **Alpha Feature**: Extensions are now available in alpha. Some features may be unstable or change in future releases.
|
||||||
|
|
||||||
|
SpotiFLAC supports extensions to add custom metadata and download providers. Extensions are written in JavaScript and run in a secure sandbox.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Metadata Providers**: Add new sources for track/album/artist search
|
||||||
|
- **Download Providers**: Add new sources for audio downloads
|
||||||
|
- **Custom Settings**: Extensions can have user-configurable settings
|
||||||
|
- **Provider Priority**: Set the order in which providers are tried
|
||||||
|
|
||||||
|
### Installing Extensions
|
||||||
|
1. Download a `.spotiflac-ext` file
|
||||||
|
2. Go to **Settings > Extensions**
|
||||||
|
3. Tap **Install Extension** and select the file
|
||||||
|
4. Configure extension settings if needed
|
||||||
|
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||||
|
|
||||||
|
### Developing Extensions
|
||||||
|
Want to create your own extension? Check out the [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md) for complete documentation.
|
||||||
|
|
||||||
|
### Example Extensions
|
||||||
|
Sample extensions are available in the [docs/extensions_example](docs/extensions_example) folder:
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
|
|||||||
@@ -218,6 +218,12 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"hasSpotifyCredentials" -> {
|
||||||
|
val hasCredentials = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.checkSpotifyCredentials()
|
||||||
|
}
|
||||||
|
result.success(hasCredentials)
|
||||||
|
}
|
||||||
"preWarmTrackCache" -> {
|
"preWarmTrackCache" -> {
|
||||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -317,6 +323,249 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
// Extension System methods
|
||||||
|
"initExtensionSystem" -> {
|
||||||
|
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
||||||
|
val dataDir = call.argument<String>("data_dir") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.initExtensionSystem(extensionsDir, dataDir)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"loadExtensionsFromDir" -> {
|
||||||
|
val dirPath = call.argument<String>("dir_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.loadExtensionsFromDir(dirPath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"loadExtensionFromPath" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.loadExtensionFromPath(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"unloadExtension" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.unloadExtensionByID(extensionId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"removeExtension" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.removeExtensionByID(extensionId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"upgradeExtension" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.upgradeExtensionFromPath(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"checkExtensionUpgrade" -> {
|
||||||
|
val filePath = call.argument<String>("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<String>("extension_id") ?: ""
|
||||||
|
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setExtensionEnabledByID(extensionId, enabled)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"setProviderPriority" -> {
|
||||||
|
val priorityJson = call.argument<String>("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<String>("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<String>("extension_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getExtensionSettingsJSON(extensionId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"setExtensionSettings" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val settingsJson = call.argument<String>("settings") ?: "{}"
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setExtensionSettingsJSON(extensionId, settingsJson)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"searchTracksWithExtensions" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val limit = call.argument<Int>("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<String>("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<String>("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<String>("extension_id") ?: ""
|
||||||
|
val authCode = call.argument<String>("auth_code") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setExtensionAuthCodeByID(extensionId, authCode)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"setExtensionTokens" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val accessToken = call.argument<String>("access_token") ?: ""
|
||||||
|
val refreshToken = call.argument<String>("refresh_token") ?: ""
|
||||||
|
val expiresIn = call.argument<Int>("expires_in") ?: 0
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setExtensionTokensByID(extensionId, accessToken, refreshToken, expiresIn.toLong())
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"clearExtensionPendingAuth" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearExtensionPendingAuthByID(extensionId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"isExtensionAuthenticated" -> {
|
||||||
|
val extensionId = call.argument<String>("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<String>("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<String>("command_id") ?: ""
|
||||||
|
val success = call.argument<Boolean>("success") ?: false
|
||||||
|
val output = call.argument<String>("output") ?: ""
|
||||||
|
val error = call.argument<String>("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<String>("extension_id") ?: ""
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val optionsJson = call.argument<String>("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 Post-Processing API
|
||||||
|
"runPostProcessing" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.runPostProcessingJSON(filePath, metadataJson)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getPostProcessingProviders" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getPostProcessingProvidersJSON()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
|||||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||||
// This uses submit → poll → download mechanism
|
// This uses submit → poll → download mechanism
|
||||||
// Internal function - not exported to gomobile
|
// 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
|
var lastError error
|
||||||
|
|
||||||
for _, region := range a.regions {
|
for _, region := range a.regions {
|
||||||
|
|||||||
+558
-17
@@ -32,18 +32,26 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||||
// Pass empty strings to use default credentials
|
|
||||||
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||||
SetSpotifyCredentials(clientID, clientSecret)
|
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
|
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||||
// Returns JSON with track/album/playlist data
|
// Returns JSON with track/album/playlist data
|
||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -63,7 +71,10 @@ func SearchSpotify(query string, limit int) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
results, err := client.SearchTracks(ctx, query, limit)
|
results, err := client.SearchTracks(ctx, query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -83,7 +94,10 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -135,6 +149,7 @@ type DownloadRequest struct {
|
|||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
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
|
// DownloadResponse represents the result of a download
|
||||||
@@ -152,10 +167,14 @@ type DownloadResponse struct {
|
|||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Artist string `json:"artist,omitempty"`
|
Artist string `json:"artist,omitempty"`
|
||||||
Album string `json:"album,omitempty"`
|
Album string `json:"album,omitempty"`
|
||||||
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ISRC string `json:"isrc,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
|
// DownloadResult is a generic result type for all downloaders
|
||||||
@@ -888,21 +907,26 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Try Spotify first
|
// Try Spotify first
|
||||||
client := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
if err != nil {
|
||||||
if err == nil {
|
// No Spotify credentials - fall through to Deezer fallback
|
||||||
jsonBytes, err := json.Marshal(data)
|
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||||
if err != nil {
|
} 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 "", 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
|
// Rate limited - try Deezer fallback for tracks and albums
|
||||||
@@ -1016,3 +1040,520 @@ func errorResponse(msg string) (string, error) {
|
|||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
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 ====================
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,986 @@
|
|||||||
|
// 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
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path within the zip
|
||||||
|
destPath := filepath.Join(extDir, filepath.Base(file.Name))
|
||||||
|
|
||||||
|
// 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: true,
|
||||||
|
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 if already exists (for directory loading on startup)
|
||||||
|
if _, exists := m.extensions[manifest.Name]; exists {
|
||||||
|
return nil, fmt.Errorf("Extension '%s' is already loaded", manifest.DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: true,
|
||||||
|
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 (we want to preserve it)
|
||||||
|
extDataDir := existing.DataDir
|
||||||
|
extDir := existing.SourceDir
|
||||||
|
|
||||||
|
// 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
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path within the zip
|
||||||
|
destPath := filepath.Join(extDir, filepath.Base(file.Name))
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: newManifest.Name,
|
||||||
|
Manifest: newManifest,
|
||||||
|
Enabled: true,
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,19 @@ go 1.24.0
|
|||||||
toolchain go1.24.5
|
toolchain go1.24.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/go-flac/flacpicture v0.3.0
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/sync v0.19.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
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
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 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
||||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
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 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
||||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
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 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
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 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
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=
|
||||||
|
|||||||
+44
-45
@@ -22,11 +22,11 @@ import (
|
|||||||
func getRandomUserAgent() string {
|
func getRandomUserAgent() string {
|
||||||
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
||||||
// Some APIs may block mobile User-Agents, so we use desktop format
|
// Some APIs may block mobile User-Agents, so we use desktop format
|
||||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
||||||
|
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||||
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
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(
|
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",
|
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||||
@@ -39,46 +39,48 @@ func getRandomUserAgent() string {
|
|||||||
|
|
||||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||||
func getRandomMacUserAgent() string {
|
// Kept for potential future use
|
||||||
macMajor := rand.Intn(4) + 11 // macOS 11-14
|
// func getRandomMacUserAgent() string {
|
||||||
macMinor := rand.Intn(5) + 4 // Minor 4-8
|
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||||
webkitMajor := rand.Intn(7) + 530
|
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||||
webkitMinor := rand.Intn(7) + 30
|
// webkitMajor := rand.Intn(7) + 530
|
||||||
chromeMajor := rand.Intn(25) + 80
|
// webkitMinor := rand.Intn(7) + 30
|
||||||
chromeBuild := rand.Intn(1500) + 3000
|
// chromeMajor := rand.Intn(25) + 80
|
||||||
chromePatch := rand.Intn(65) + 60
|
// chromeBuild := rand.Intn(1500) + 3000
|
||||||
safariMajor := rand.Intn(7) + 530
|
// chromePatch := rand.Intn(65) + 60
|
||||||
safariMinor := rand.Intn(6) + 30
|
// 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",
|
// return fmt.Sprintf(
|
||||||
macMajor,
|
// "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",
|
||||||
macMinor,
|
// macMajor,
|
||||||
webkitMajor,
|
// macMinor,
|
||||||
webkitMinor,
|
// webkitMajor,
|
||||||
chromeMajor,
|
// webkitMinor,
|
||||||
chromeBuild,
|
// chromeMajor,
|
||||||
chromePatch,
|
// chromeBuild,
|
||||||
safariMajor,
|
// chromePatch,
|
||||||
safariMinor,
|
// safariMajor,
|
||||||
)
|
// safariMinor,
|
||||||
}
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||||
func getRandomDesktopUserAgent() string {
|
// Kept for potential future use
|
||||||
if rand.Intn(2) == 0 {
|
// func getRandomDesktopUserAgent() string {
|
||||||
return getRandomUserAgent() // Windows
|
// if rand.Intn(2) == 0 {
|
||||||
}
|
// return getRandomUserAgent() // Windows
|
||||||
return getRandomMacUserAgent() // Mac
|
// }
|
||||||
}
|
// return getRandomMacUserAgent() // Mac
|
||||||
|
// }
|
||||||
|
|
||||||
// Default timeout values
|
// Default timeout values
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
||||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
||||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
||||||
DefaultMaxRetries = 3 // Default retry count
|
DefaultMaxRetries = 3 // Default retry count
|
||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
@@ -96,9 +98,9 @@ var sharedTransport = &http.Transport{
|
|||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||||
DisableCompression: true, // FLAC is already compressed
|
DisableCompression: true, // FLAC is already compressed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for general requests (reuses connections)
|
// Shared HTTP client for general requests (reuses connections)
|
||||||
@@ -267,10 +269,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
// calculateNextDelay calculates the next delay with exponential backoff
|
// calculateNextDelay calculates the next delay with exponential backoff
|
||||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||||
if nextDelay > config.MaxDelay {
|
return min(nextDelay, config.MaxDelay)
|
||||||
nextDelay = config.MaxDelay
|
|
||||||
}
|
|
||||||
return nextDelay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
// getRetryAfterDuration parses Retry-After header and returns duration
|
||||||
|
|||||||
+24
-23
@@ -250,29 +250,30 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
|
|
||||||
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||||
// Use convertToLRCWithMetadata for full LRC with headers
|
// Use convertToLRCWithMetadata for full LRC with headers
|
||||||
func convertToLRC(lyrics *LyricsResponse) string {
|
// Kept for potential future use
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||||
return ""
|
// if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
}
|
// return ""
|
||||||
|
// }
|
||||||
var builder strings.Builder
|
//
|
||||||
|
// var builder strings.Builder
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
//
|
||||||
for _, line := range lyrics.Lines {
|
// if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
// for _, line := range lyrics.Lines {
|
||||||
builder.WriteString(timestamp)
|
// timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||||
builder.WriteString(line.Words)
|
// builder.WriteString(timestamp)
|
||||||
builder.WriteString("\n")
|
// builder.WriteString(line.Words)
|
||||||
}
|
// builder.WriteString("\n")
|
||||||
} else {
|
// }
|
||||||
for _, line := range lyrics.Lines {
|
// } else {
|
||||||
builder.WriteString(line.Words)
|
// for _, line := range lyrics.Lines {
|
||||||
builder.WriteString("\n")
|
// builder.WriteString(line.Words)
|
||||||
}
|
// builder.WriteString("\n")
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
return builder.String()
|
//
|
||||||
}
|
// return builder.String()
|
||||||
|
// }
|
||||||
|
|
||||||
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||||
// Includes [ti:], [ar:], [by:] headers
|
// Includes [ti:], [ar:], [by:] headers
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmTidalCache(isrc, trackName, artistName string) {
|
func preWarmTidalCache(isrc, _, _ string) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type ItemProgress struct {
|
|||||||
ItemID string `json:"item_id"`
|
ItemID string `json:"item_id"`
|
||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
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
|
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
@@ -204,11 +204,12 @@ func setDownloadDir(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadDir returns the default download directory
|
// getDownloadDir returns the default download directory
|
||||||
func getDownloadDir() string {
|
// Kept for potential future use
|
||||||
downloadDirMu.RLock()
|
// func getDownloadDir() string {
|
||||||
defer downloadDirMu.RUnlock()
|
// downloadDirMu.RLock()
|
||||||
return downloadDir
|
// defer downloadDirMu.RUnlock()
|
||||||
}
|
// return downloadDir
|
||||||
|
// }
|
||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
|
|||||||
+9
-8
@@ -271,14 +271,15 @@ func qobuzIsLatinScript(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
func qobuzIsASCIIString(s string) bool {
|
// Kept for potential future use
|
||||||
for _, r := range s {
|
// func qobuzIsASCIIString(s string) bool {
|
||||||
if r > 127 {
|
// for _, r := range s {
|
||||||
return false
|
// if r > 127 {
|
||||||
}
|
// return false
|
||||||
}
|
// }
|
||||||
return true
|
// }
|
||||||
}
|
// return true
|
||||||
|
// }
|
||||||
|
|
||||||
// containsQueryQobuz checks if a query already exists in the list
|
// containsQueryQobuz checks if a query already exists in the list
|
||||||
func containsQueryQobuz(queries []string, query string) bool {
|
func containsQueryQobuz(queries []string, query string) bool {
|
||||||
|
|||||||
+63
-45
@@ -2,7 +2,6 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,13 +16,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||||
|
|
||||||
// Cache TTL settings
|
// Cache TTL settings
|
||||||
artistCacheTTL = 10 * time.Minute
|
artistCacheTTL = 10 * time.Minute
|
||||||
@@ -69,8 +68,10 @@ var (
|
|||||||
credentialsMu sync.RWMutex
|
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
|
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||||
// Pass empty strings to use default credentials
|
|
||||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
credentialsMu.Lock()
|
credentialsMu.Lock()
|
||||||
defer credentialsMu.Unlock()
|
defer credentialsMu.Unlock()
|
||||||
@@ -78,39 +79,56 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
|||||||
customClientSecret = clientSecret
|
customClientSecret = clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCredentials returns the current credentials (custom or default)
|
// HasSpotifyCredentials checks if Spotify credentials are configured
|
||||||
func getCredentials() (string, string) {
|
func HasSpotifyCredentials() bool {
|
||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
// Check custom credentials first
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return customClientID, customClientSecret
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to default credentials
|
// Check environment variables
|
||||||
|
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
if clientID == "" {
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
|
||||||
clientID = string(decoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
if clientSecret == "" {
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
if clientID != "" && clientSecret != "" {
|
||||||
clientSecret = string(decoded)
|
return clientID, clientSecret, nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return clientID, clientSecret
|
// No credentials available
|
||||||
|
return "", "", ErrNoSpotifyCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
// Returns error if credentials are not configured
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
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)
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
clientID, clientSecret := getCredentials()
|
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||||
@@ -122,7 +140,7 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
|||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
}
|
}
|
||||||
c.userAgent = c.randomUserAgent()
|
c.userAgent = c.randomUserAgent()
|
||||||
return c
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackMetadata represents track information
|
// TrackMetadata represents track information
|
||||||
@@ -395,10 +413,10 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
Artists struct {
|
Artists struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Followers struct {
|
Followers struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"followers"`
|
} `json:"followers"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
@@ -755,10 +773,10 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
|
|
||||||
// Fetch artist info
|
// Fetch artist info
|
||||||
var artistData struct {
|
var artistData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Followers struct {
|
Followers struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"followers"`
|
} `json:"followers"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
@@ -941,15 +959,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
// Use Mac User-Agent format (same as PC version)
|
// Use Mac User-Agent format (same as PC version)
|
||||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||||
|
|
||||||
return fmt.Sprintf(
|
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",
|
"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",
|
||||||
|
|||||||
+43
-172
@@ -345,27 +345,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
|
// normalizeTitle normalizes a track title for comparison
|
||||||
func normalizeTitle(title string) string {
|
// Kept for potential future use
|
||||||
normalized := strings.ToLower(strings.TrimSpace(title))
|
// func normalizeTitle(title string) string {
|
||||||
|
// normalized := strings.ToLower(strings.TrimSpace(title))
|
||||||
// Remove common suffixes in parentheses or brackets
|
//
|
||||||
suffixPatterns := []string{
|
// // Remove common suffixes in parentheses or brackets
|
||||||
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
// suffixPatterns := []string{
|
||||||
" (bonus track)", " (single)", " (album version)", " (radio edit)",
|
// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||||
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
// " (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||||
}
|
// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||||
for _, suffix := range suffixPatterns {
|
// }
|
||||||
normalized = strings.TrimSuffix(normalized, suffix)
|
// for _, suffix := range suffixPatterns {
|
||||||
}
|
// normalized = strings.TrimSuffix(normalized, suffix)
|
||||||
|
// }
|
||||||
// Remove multiple spaces
|
//
|
||||||
for strings.Contains(normalized, " ") {
|
// // Remove multiple spaces
|
||||||
normalized = strings.ReplaceAll(normalized, " ", " ")
|
// for strings.Contains(normalized, " ") {
|
||||||
}
|
// normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||||
|
// }
|
||||||
return normalized
|
//
|
||||||
}
|
// return normalized
|
||||||
|
// }
|
||||||
|
|
||||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||||
@@ -639,151 +640,20 @@ type TidalDownloadInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// tidalAPIResult holds the result from a parallel API request
|
// tidalAPIResult holds the result from a parallel API request
|
||||||
type tidalAPIResult struct {
|
// Kept for potential future use with _getDownloadURLParallel
|
||||||
apiURL string
|
// type tidalAPIResult struct {
|
||||||
info TidalDownloadInfo
|
// apiURL string
|
||||||
err error
|
// info TidalDownloadInfo
|
||||||
duration time.Duration
|
// err error
|
||||||
}
|
// duration time.Duration
|
||||||
|
// }
|
||||||
|
|
||||||
// getDownloadURLParallel requests download URL from all APIs in parallel
|
// _getDownloadURLParallel requests download URL from all APIs in parallel
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
// Kept for potential future use - currently using sequential approach
|
||||||
if len(apis) == 0 {
|
// func _getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
// ... implementation commented out ...
|
||||||
}
|
// }
|
||||||
|
|
||||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
|
||||||
|
|
||||||
resultChan := make(chan tidalAPIResult, len(apis))
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
// Start all requests in parallel
|
|
||||||
for _, apiURL := range apis {
|
|
||||||
go func(api string) {
|
|
||||||
reqStart := time.Now()
|
|
||||||
|
|
||||||
// Create client with longer 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try v2 format first (object with manifest)
|
|
||||||
var v2Response TidalAPIResponseV2
|
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
|
||||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
|
||||||
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,
|
|
||||||
SampleRate: v2Response.Data.SampleRate,
|
|
||||||
}
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 != "" {
|
|
||||||
GoLog("[Tidal] [Parallel] %s - Got direct URL (v1) in %v\n", api, time.Since(reqStart))
|
|
||||||
info := TidalDownloadInfo{
|
|
||||||
URL: item.OriginalTrackURL,
|
|
||||||
BitDepth: 16,
|
|
||||||
SampleRate: 44100,
|
|
||||||
}
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
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] 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)
|
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||||
@@ -1473,14 +1343,15 @@ func isLatinScript(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isASCIIString checks if a string contains only ASCII characters
|
// isASCIIString checks if a string contains only ASCII characters
|
||||||
func isASCIIString(s string) bool {
|
// Kept for potential future use
|
||||||
for _, r := range s {
|
// func isASCIIString(s string) bool {
|
||||||
if r > 127 {
|
// for _, r := range s {
|
||||||
return false
|
// if r > 127 {
|
||||||
}
|
// return false
|
||||||
}
|
// }
|
||||||
return true
|
// }
|
||||||
}
|
// return true
|
||||||
|
// }
|
||||||
|
|
||||||
// downloadFromTidal downloads a track using the request parameters
|
// downloadFromTidal downloads a track using the request parameters
|
||||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
|
|||||||
@@ -256,6 +256,10 @@ import Gobackend // Import Go framework
|
|||||||
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "hasSpotifyCredentials":
|
||||||
|
let hasCredentials = GobackendCheckSpotifyCredentials()
|
||||||
|
return hasCredentials
|
||||||
|
|
||||||
// Log methods
|
// Log methods
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
let response = GobackendGetLogs()
|
let response = GobackendGetLogs()
|
||||||
@@ -281,6 +285,219 @@ import Gobackend // Import Go framework
|
|||||||
GobackendSetLoggingEnabled(enabled)
|
GobackendSetLoggingEnabled(enabled)
|
||||||
return nil
|
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 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
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.2.7';
|
static const String version = '3.0.0-alpha.2';
|
||||||
static const String buildNumber = '49';
|
static const String buildNumber = '51';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+34
-3
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/app.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.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/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/share_intent_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
|
/// 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});
|
const _EagerInitialization({required this.child});
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
// Eagerly initialize download history provider to load from storage
|
||||||
ref.watch(downloadHistoryProvider);
|
ref.watch(downloadHistoryProvider);
|
||||||
return child;
|
return widget.child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ class AppSettings {
|
|||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode; // list, grid
|
||||||
|
final String historyFilterMode; // all, albums, singles
|
||||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
final bool enableLogging; // Enable detailed logging for debugging
|
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
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -40,12 +43,15 @@ class AppSettings {
|
|||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
|
this.historyFilterMode = 'all', // Default: show all
|
||||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||||
this.spotifyClientId = '', // Default: use built-in credentials
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||||
this.enableLogging = false, // Default: disabled for performance
|
this.enableLogging = false, // Default: disabled for performance
|
||||||
|
this.useExtensionProviders = true, // Default: use extensions when available
|
||||||
|
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -63,12 +69,15 @@ class AppSettings {
|
|||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
|
String? historyFilterMode,
|
||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
String? spotifyClientId,
|
String? spotifyClientId,
|
||||||
String? spotifyClientSecret,
|
String? spotifyClientSecret,
|
||||||
bool? useCustomSpotifyCredentials,
|
bool? useCustomSpotifyCredentials,
|
||||||
String? metadataSource,
|
String? metadataSource,
|
||||||
bool? enableLogging,
|
bool? enableLogging,
|
||||||
|
bool? useExtensionProviders,
|
||||||
|
String? searchProvider,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -85,12 +94,15 @@ class AppSettings {
|
|||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
|
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
metadataSource: metadataSource ?? this.metadataSource,
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
enableLogging: enableLogging ?? this.enableLogging,
|
enableLogging: enableLogging ?? this.enableLogging,
|
||||||
|
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||||
|
searchProvider: searchProvider ?? this.searchProvider,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
|
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
@@ -28,6 +29,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||||
|
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||||
|
searchProvider: json['searchProvider'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -46,10 +49,13 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
|
'historyFilterMode': instance.historyFilterMode,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
'spotifyClientId': instance.spotifyClientId,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
'metadataSource': instance.metadataSource,
|
'metadataSource': instance.metadataSource,
|
||||||
'enableLogging': instance.enableLogging,
|
'enableLogging': instance.enableLogging,
|
||||||
|
'useExtensionProviders': instance.useExtensionProviders,
|
||||||
|
'searchProvider': instance.searchProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class Track {
|
|||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
final String? deezerId;
|
final String? deezerId;
|
||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
|
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -33,10 +34,14 @@ class Track {
|
|||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
this.deezerId,
|
this.deezerId,
|
||||||
this.availability,
|
this.availability,
|
||||||
|
this.source,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||||
|
|
||||||
|
/// Check if this track is from an extension
|
||||||
|
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
: ServiceAvailability.fromJson(
|
: ServiceAvailability.fromJson(
|
||||||
json['availability'] as Map<String, dynamic>,
|
json['availability'] as Map<String, dynamic>,
|
||||||
),
|
),
|
||||||
|
source: json['source'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||||
@@ -40,6 +41,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
'deezerId': instance.deezerId,
|
'deezerId': instance.deezerId,
|
||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
|
'source': instance.source,
|
||||||
};
|
};
|
||||||
|
|
||||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/models/download_item.dart';
|
|||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.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/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
@@ -831,6 +832,56 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_saveQueueToStorage(); // Persist queue
|
_saveQueueToStorage(); // Persist queue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run post-processing hooks on a downloaded file
|
||||||
|
Future<void> _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 = <String, dynamic>{
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||||
// Download cover first
|
// Download cover first
|
||||||
@@ -1282,7 +1333,37 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
Map<String, dynamic> result;
|
Map<String, dynamic> 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('Using auto-fallback mode');
|
||||||
_log.d(
|
_log.d(
|
||||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||||
@@ -1502,6 +1583,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Run post-processing hooks if enabled
|
||||||
|
if (filePath != null) {
|
||||||
|
await _runPostProcessingHooks(filePath, trackToDownload);
|
||||||
|
}
|
||||||
|
|
||||||
// Increment completed counter
|
// Increment completed counter
|
||||||
_completedInSession++;
|
_completedInSession++;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,677 @@
|
|||||||
|
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<String> permissions;
|
||||||
|
final List<ExtensionSetting> settings;
|
||||||
|
final List<QualityOption> 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 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.trackMatching,
|
||||||
|
this.postProcessing,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Extension.fromJson(Map<String, dynamic> 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<dynamic>?)?.cast<String>() ?? [],
|
||||||
|
settings: (json['settings'] as List<dynamic>?)
|
||||||
|
?.map((s) => ExtensionSetting.fromJson(s as Map<String, dynamic>))
|
||||||
|
.toList() ?? [],
|
||||||
|
qualityOptions: (json['quality_options'] as List<dynamic>?)
|
||||||
|
?.map((q) => QualityOption.fromJson(q as Map<String, dynamic>))
|
||||||
|
.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<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
trackMatching: json['track_matching'] != null
|
||||||
|
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
postProcessing: json['post_processing'] != null
|
||||||
|
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Extension copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? displayName,
|
||||||
|
String? version,
|
||||||
|
String? author,
|
||||||
|
String? description,
|
||||||
|
bool? enabled,
|
||||||
|
String? status,
|
||||||
|
String? errorMessage,
|
||||||
|
String? iconPath,
|
||||||
|
List<String>? permissions,
|
||||||
|
List<ExtensionSetting>? settings,
|
||||||
|
List<QualityOption>? qualityOptions,
|
||||||
|
bool? hasMetadataProvider,
|
||||||
|
bool? hasDownloadProvider,
|
||||||
|
bool? skipMetadataEnrichment,
|
||||||
|
SearchBehavior? searchBehavior,
|
||||||
|
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,
|
||||||
|
trackMatching: trackMatching ?? this.trackMatching,
|
||||||
|
postProcessing: postProcessing ?? this.postProcessing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasCustomSearch => searchBehavior?.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<String, dynamic> 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<String, dynamic> 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<PostProcessingHook> hooks;
|
||||||
|
|
||||||
|
const PostProcessing({
|
||||||
|
required this.enabled,
|
||||||
|
this.hooks = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PostProcessing.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PostProcessing(
|
||||||
|
enabled: json['enabled'] as bool? ?? false,
|
||||||
|
hooks: (json['hooks'] as List<dynamic>?)
|
||||||
|
?.map((h) => PostProcessingHook.fromJson(h as Map<String, dynamic>))
|
||||||
|
.toList() ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A post-processing hook
|
||||||
|
class PostProcessingHook {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? description;
|
||||||
|
final bool defaultEnabled;
|
||||||
|
final List<String> supportedFormats;
|
||||||
|
|
||||||
|
const PostProcessingHook({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
|
this.defaultEnabled = false,
|
||||||
|
this.supportedFormats = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PostProcessingHook.fromJson(Map<String, dynamic> 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<dynamic>?)?.cast<String>() ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a quality option for download providers
|
||||||
|
class QualityOption {
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String? description;
|
||||||
|
final List<QualitySpecificSetting> settings; // Quality-specific settings
|
||||||
|
|
||||||
|
const QualityOption({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
this.description,
|
||||||
|
this.settings = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QualityOption.fromJson(Map<String, dynamic> json) {
|
||||||
|
return QualityOption(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
label: json['label'] as String? ?? '',
|
||||||
|
description: json['description'] as String?,
|
||||||
|
settings: (json['settings'] as List<dynamic>?)
|
||||||
|
?.map((s) => QualitySpecificSetting.fromJson(s as Map<String, dynamic>))
|
||||||
|
.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<String>? 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<String, dynamic> 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<dynamic>?)?.cast<String>(),
|
||||||
|
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<String>? 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<String, dynamic> 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<dynamic>?)?.cast<String>(),
|
||||||
|
required: json['required'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for extension management
|
||||||
|
class ExtensionState {
|
||||||
|
final List<Extension> extensions;
|
||||||
|
final List<String> providerPriority;
|
||||||
|
final List<String> 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<Extension>? extensions,
|
||||||
|
List<String>? providerPriority,
|
||||||
|
List<String>? 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<ExtensionState> {
|
||||||
|
@override
|
||||||
|
ExtensionState build() {
|
||||||
|
return const ExtensionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the extension system
|
||||||
|
Future<void> 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<void> 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<void> 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<bool> 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<Map<String, dynamic>> 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<bool> 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<bool> 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<void> 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<Map<String, dynamic>> 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<void> setExtensionSettings(String extensionId, Map<String, dynamic> 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<void> 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<void> setProviderPriority(List<String> 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<void> 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<void> setMetadataProviderPriority(List<String> 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<void> 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<Extension> get enabledExtensions {
|
||||||
|
return state.extensions.where((ext) => ext.enabled).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all download providers (built-in + extensions)
|
||||||
|
List<String> 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<String> 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<Extension> get searchProviders {
|
||||||
|
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
|
||||||
|
ExtensionNotifier.new,
|
||||||
|
);
|
||||||
@@ -60,18 +60,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
|
|
||||||
/// Apply current Spotify credentials to Go backend
|
/// Apply current Spotify credentials to Go backend
|
||||||
Future<void> _applySpotifyCredentials() async {
|
Future<void> _applySpotifyCredentials() async {
|
||||||
// Only apply custom credentials if enabled and both fields are set
|
// Only apply if both fields are set
|
||||||
if (state.useCustomSpotifyCredentials &&
|
if (state.spotifyClientId.isNotEmpty &&
|
||||||
state.spotifyClientId.isNotEmpty &&
|
|
||||||
state.spotifyClientSecret.isNotEmpty) {
|
state.spotifyClientSecret.isNotEmpty) {
|
||||||
await PlatformBridge.setSpotifyCredentials(
|
await PlatformBridge.setSpotifyCredentials(
|
||||||
state.spotifyClientId,
|
state.spotifyClientId,
|
||||||
state.spotifyClientSecret,
|
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) {
|
void setDefaultService(String service) {
|
||||||
@@ -148,6 +146,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setHistoryFilterMode(String mode) {
|
||||||
|
state = state.copyWith(historyFilterMode: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setAskQualityBeforeDownload(bool enabled) {
|
void setAskQualityBeforeDownload(bool enabled) {
|
||||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -192,12 +195,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setSearchProvider(String? provider) {
|
||||||
|
state = state.copyWith(searchProvider: provider);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setEnableLogging(bool enabled) {
|
void setEnableLogging(bool enabled) {
|
||||||
state = state.copyWith(enableLogging: enabled);
|
state = state.copyWith(enableLogging: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
// Sync logging state to LogBuffer
|
// Sync logging state to LogBuffer
|
||||||
LogBuffer.loggingEnabled = enabled;
|
LogBuffer.loggingEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setUseExtensionProviders(bool enabled) {
|
||||||
|
state = state.copyWith(useExtensionProviders: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.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');
|
final _log = AppLogger('TrackProvider');
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ class TrackState {
|
|||||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
final List<SearchArtist>? searchArtists; // For search results
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText; // For back button handling
|
||||||
|
final String? searchExtensionId; // Extension ID used for current search results
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
@@ -32,6 +35,7 @@ class TrackState {
|
|||||||
this.artistAlbums,
|
this.artistAlbums,
|
||||||
this.searchArtists,
|
this.searchArtists,
|
||||||
this.hasSearchText = false,
|
this.hasSearchText = false,
|
||||||
|
this.searchExtensionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||||
@@ -49,6 +53,7 @@ class TrackState {
|
|||||||
List<ArtistAlbum>? artistAlbums,
|
List<ArtistAlbum>? artistAlbums,
|
||||||
List<SearchArtist>? searchArtists,
|
List<SearchArtist>? searchArtists,
|
||||||
bool? hasSearchText,
|
bool? hasSearchText,
|
||||||
|
String? searchExtensionId,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
tracks: tracks ?? this.tracks,
|
tracks: tracks ?? this.tracks,
|
||||||
@@ -63,6 +68,7 @@ class TrackState {
|
|||||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
searchArtists: searchArtists ?? this.searchArtists,
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
|
searchExtensionId: searchExtensionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,12 +216,43 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
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
|
// Use Deezer or Spotify based on settings
|
||||||
final source = metadataSource ?? 'deezer';
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
_log.i('Search started: source=$source, query="$query"');
|
_log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions');
|
||||||
|
|
||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
|
List<Track> extensionTracks = [];
|
||||||
|
|
||||||
|
// Try extension providers first if enabled
|
||||||
|
if (useExtensions) {
|
||||||
|
try {
|
||||||
|
_log.d('Calling extension search API...');
|
||||||
|
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
||||||
|
_log.i('Extensions returned ${extResults.length} tracks');
|
||||||
|
|
||||||
|
// Parse extension results
|
||||||
|
for (final t in extResults) {
|
||||||
|
try {
|
||||||
|
extensionTracks.add(_parseSearchTrack(t));
|
||||||
|
} 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') {
|
if (source == 'deezer') {
|
||||||
_log.d('Calling Deezer search API...');
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
@@ -238,11 +275,26 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
// Parse tracks with error handling per item
|
// Parse tracks with error handling per item
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
|
|
||||||
|
// Add extension tracks first (they have priority)
|
||||||
|
tracks.addAll(extensionTracks);
|
||||||
|
|
||||||
|
// Add built-in provider tracks, avoiding duplicates by ISRC
|
||||||
|
final existingIsrcs = extensionTracks
|
||||||
|
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||||
|
.map((t) => t.isrc!)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
for (int i = 0; i < trackList.length; i++) {
|
for (int i = 0; i < trackList.length; i++) {
|
||||||
final t = trackList[i];
|
final t = trackList[i];
|
||||||
try {
|
try {
|
||||||
if (t is Map<String, dynamic>) {
|
if (t is Map<String, dynamic>) {
|
||||||
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 {
|
} else {
|
||||||
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
|
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
|
||||||
}
|
}
|
||||||
@@ -266,7 +318,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_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(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -281,6 +333,53 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Perform custom search using a specific extension
|
||||||
|
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||||
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
|
// Preserve hasSearchText during search
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
|
try {
|
||||||
|
_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 = <Track>[];
|
||||||
|
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<void> checkAvailability(int index) async {
|
Future<void> checkAvailability(int index) async {
|
||||||
if (index < 0 || index >= state.tracks.length) return;
|
if (index < 0 || index >= state.tracks.length) return;
|
||||||
|
|
||||||
@@ -344,7 +443,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Track _parseSearchTrack(Map<String, dynamic> data) {
|
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||||
// Handle duration_ms which might be int or double
|
// Handle duration_ms which might be int or double
|
||||||
int durationMs = 0;
|
int durationMs = 0;
|
||||||
final durationValue = data['duration_ms'];
|
final durationValue = data['duration_ms'];
|
||||||
@@ -366,6 +465,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
|
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-218
@@ -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/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.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
|
/// Simple in-memory cache for album tracks
|
||||||
class _AlbumCache {
|
class _AlbumCache {
|
||||||
@@ -316,10 +317,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
void _downloadTrack(BuildContext context, Track track) {
|
void _downloadTrack(BuildContext context, Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality, service) {
|
DownloadServicePicker.show(
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
context,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
trackName: track.name,
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
@@ -331,84 +338,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
if (tracks == null || tracks.isEmpty) return;
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality, service) {
|
DownloadServicePicker.show(
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
context,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
trackName: '${tracks.length} tracks',
|
||||||
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('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)
|
/// Build error widget with special handling for rate limit (429)
|
||||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
final isRateLimit = error.contains('429') ||
|
final isRateLimit = error.contains('429') ||
|
||||||
@@ -473,148 +417,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
class _AlbumTrackItem extends ConsumerWidget {
|
class _AlbumTrackItem extends ConsumerWidget {
|
||||||
final Track track;
|
final Track track;
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
|
||||||
|
/// Screen to display downloaded tracks from a specific album
|
||||||
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
|
final String albumName;
|
||||||
|
final String artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
|
||||||
|
const DownloadedAlbumScreen({
|
||||||
|
super.key,
|
||||||
|
required this.albumName,
|
||||||
|
required this.artistName,
|
||||||
|
this.coverUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
|
// Multi-select state
|
||||||
|
bool _isSelectionMode = false;
|
||||||
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
/// Get tracks for this album from history provider (reactive)
|
||||||
|
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||||
|
return allItems.where((item) {
|
||||||
|
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
|
final albumKey = '${widget.albumName}|${widget.artistName}';
|
||||||
|
return itemKey == albumKey;
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final aNum = a.trackNumber ?? 999;
|
||||||
|
final bNum = b.trackNumber ?? 999;
|
||||||
|
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||||
|
return a.trackName.compareTo(b.trackName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enterSelectionMode(String itemId) {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = true;
|
||||||
|
_selectedIds.add(itemId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitSelectionMode() {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
_selectedIds.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSelection(String itemId) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedIds.contains(itemId)) {
|
||||||
|
_selectedIds.remove(itemId);
|
||||||
|
if (_selectedIds.isEmpty) {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_selectedIds.add(itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAll(List<DownloadHistoryItem> tracks) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIds.addAll(tracks.map((e) => e.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteSelected(List<DownloadHistoryItem> currentTracks) async {
|
||||||
|
final count = _selectedIds.length;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete Selected'),
|
||||||
|
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true && mounted) {
|
||||||
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||||
|
final idsToDelete = _selectedIds.toList();
|
||||||
|
|
||||||
|
int deletedCount = 0;
|
||||||
|
for (final id in idsToDelete) {
|
||||||
|
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||||
|
if (item != null) {
|
||||||
|
try {
|
||||||
|
final file = File(item.filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
historyNotifier.removeFromHistory(id);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_exitSelectionMode();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openFile(String filePath) async {
|
||||||
|
try {
|
||||||
|
await OpenFilex.open(filePath);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Cannot open file: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToMetadataScreen(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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
|
// Watch history and get tracks for this album (reactive!)
|
||||||
|
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
|
final tracks = _getAlbumTracks(allHistoryItems);
|
||||||
|
|
||||||
|
// Auto-pop if album has less than 2 tracks (no longer an "album")
|
||||||
|
if (tracks.length < 2) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
});
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up selected IDs that no longer exist
|
||||||
|
final validIds = tracks.map((t) => t.id).toSet();
|
||||||
|
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||||
|
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() => _isSelectionMode = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isSelectionMode,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop && _isSelectionMode) {
|
||||||
|
_exitSelectionMode();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme, tracks),
|
||||||
|
_buildTrackListHeader(context, colorScheme, tracks),
|
||||||
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
|
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// 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, tracks, bottomPadding),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.coverUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
memCacheWidth: 600,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.albumName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
widget.artistName,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_getCommonQuality(tracks) != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||||
|
? colorScheme.tertiaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_getCommonQuality(tracks)!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||||
|
? colorScheme.onTertiaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
||||||
|
if (tracks.isEmpty) return null;
|
||||||
|
final firstQuality = tracks.first.quality;
|
||||||
|
if (firstQuality == null) return null;
|
||||||
|
for (final track in tracks) {
|
||||||
|
if (track.quality != firstQuality) return null;
|
||||||
|
}
|
||||||
|
return firstQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
const Spacer(),
|
||||||
|
if (!_isSelectionMode)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||||
|
icon: const Icon(Icons.checklist, size: 18),
|
||||||
|
label: const Text('Select'),
|
||||||
|
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
|
||||||
|
final isSelected = _selectedIds.contains(track.id);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
onTap: _isSelectionMode
|
||||||
|
? () => _toggleSelection(track.id)
|
||||||
|
: () => _navigateToMetadataScreen(track),
|
||||||
|
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
||||||
|
leading: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_isSelectionMode) ...[
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
||||||
|
),
|
||||||
|
child: isSelected
|
||||||
|
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: Text(
|
||||||
|
track.trackNumber?.toString() ?? '-',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
track.trackName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
track.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
trailing: _isSelectionMode ? null : IconButton(
|
||||||
|
onPressed: () => _openFile(track.filePath),
|
||||||
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
|
||||||
|
final selectedCount = _selectedIds.length;
|
||||||
|
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, -4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: _exitSelectionMode,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$selectedCount selected',
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
if (allSelected) {
|
||||||
|
_exitSelectionMode();
|
||||||
|
} else {
|
||||||
|
_selectAll(tracks);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
||||||
|
label: Text(allSelected ? 'Deselect' : 'Select All'),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: Text(
|
||||||
|
selectedCount > 0
|
||||||
|
? '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,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-253
@@ -8,12 +8,14 @@ import 'package:spotiflac_android/models/track.dart';
|
|||||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
|
||||||
class HomeTab extends ConsumerStatefulWidget {
|
class HomeTab extends ConsumerStatefulWidget {
|
||||||
const HomeTab({super.key});
|
const HomeTab({super.key});
|
||||||
@@ -78,12 +80,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performSearch(String query) async {
|
Future<void> _performSearch(String query) async {
|
||||||
// Skip if same query already searched
|
|
||||||
if (_lastSearchQuery == query) return;
|
|
||||||
_lastSearchQuery = query;
|
|
||||||
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
final searchProvider = settings.searchProvider;
|
||||||
|
|
||||||
|
// Skip if same query already searched with same provider
|
||||||
|
final searchKey = '${searchProvider ?? 'default'}:$query';
|
||||||
|
if (_lastSearchQuery == searchKey) return;
|
||||||
|
_lastSearchQuery = searchKey;
|
||||||
|
|
||||||
|
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||||
|
// Use custom search from extension
|
||||||
|
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
|
||||||
|
} else {
|
||||||
|
// Use default search (Deezer/Spotify)
|
||||||
|
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||||
|
}
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,10 +184,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality, service) {
|
DownloadServicePicker.show(
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
context,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
trackName: track.name,
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
@@ -184,107 +201,23 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
||||||
// Show loading dialog with progress
|
// Show loading dialog with progress
|
||||||
int currentProgress = 0;
|
int currentProgress = 0;
|
||||||
int totalTracks = 0;
|
int totalTracks = 0;
|
||||||
|
|
||||||
// Use StatefulBuilder to update dialog content
|
// Use StatefulBuilder to update dialog content
|
||||||
final dialogContext = context;
|
|
||||||
bool dialogShown = false;
|
bool dialogShown = false;
|
||||||
StateSetter? setDialogState;
|
StateSetter? setDialogState;
|
||||||
|
|
||||||
void showProgressDialog() {
|
void showProgressDialog() {
|
||||||
if (dialogShown) return;
|
if (dialogShown || !mounted) return;
|
||||||
dialogShown = true;
|
dialogShown = true;
|
||||||
showDialog(
|
showDialog(
|
||||||
context: dialogContext,
|
context: this.context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => StatefulBuilder(
|
builder: (dialogCtx) => StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (dialogCtx, setState) {
|
||||||
setDialogState = setState;
|
setDialogState = setState;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
content: Column(
|
content: Column(
|
||||||
@@ -318,25 +251,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
|
|
||||||
// Close progress dialog
|
// Close progress dialog
|
||||||
if (dialogShown && mounted) {
|
if (dialogShown && mounted) {
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(this.context).pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracks.isNotEmpty) {
|
if (tracks.isNotEmpty) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
// Optionally show confirmation dialog
|
// Optionally show confirmation dialog
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: this.context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (dialogCtx) => AlertDialog(
|
||||||
title: const Text('Import Playlist'),
|
title: const Text('Import Playlist'),
|
||||||
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
|
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||||
child: const Text('Import'),
|
child: const Text('Import'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -346,7 +281,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Added ${tracks.length} tracks to queue'),
|
content: Text('Added ${tracks.length} tracks to queue'),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
@@ -385,6 +320,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
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 colorScheme = Theme.of(context).colorScheme;
|
||||||
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
@@ -836,6 +775,32 @@ class _HomeTabState extends ConsumerState<HomeTab> 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) {
|
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||||
final hasText = _urlController.text.isNotEmpty;
|
final hasText = _urlController.text.isNotEmpty;
|
||||||
|
|
||||||
@@ -844,7 +809,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
focusNode: _searchFocusNode,
|
focusNode: _searchFocusNode,
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Paste Spotify URL or search...',
|
hintText: _getSearchHint(),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceContainerHighest,
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
@@ -910,147 +875,6 @@ class _HomeTabState extends ConsumerState<HomeTab> 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
|
/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes
|
||||||
class _TrackItemWithStatus extends ConsumerWidget {
|
class _TrackItemWithStatus extends ConsumerWidget {
|
||||||
final Track track;
|
final Track track;
|
||||||
@@ -1080,6 +904,28 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
return state.isDownloaded(track.id);
|
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 isQueued = queueItem != null;
|
||||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||||
@@ -1100,21 +946,21 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Album art
|
// Album art with dynamic size based on extension config
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: track.coverUrl != null
|
child: track.coverUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: track.coverUrl!,
|
imageUrl: track.coverUrl!,
|
||||||
width: 56,
|
width: thumbWidth,
|
||||||
height: 56,
|
height: thumbHeight,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 112,
|
memCacheWidth: (thumbWidth * 2).toInt(),
|
||||||
memCacheHeight: 112,
|
memCacheHeight: (thumbHeight * 2).toInt(),
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
width: 56,
|
width: thumbWidth,
|
||||||
height: 56,
|
height: thumbHeight,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
@@ -1151,7 +997,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
|||||||
Divider(
|
Divider(
|
||||||
height: 1,
|
height: 1,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
indent: 80,
|
indent: thumbWidth + 24, // Adjust divider indent based on thumbnail width
|
||||||
endIndent: 12,
|
endIndent: 12,
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/models/track.dart';
|
|||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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
|
/// Playlist detail screen with Material Expressive 3 design
|
||||||
class PlaylistScreen extends ConsumerWidget {
|
class PlaylistScreen extends ConsumerWidget {
|
||||||
@@ -168,10 +169,16 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, ref, (quality, service) {
|
DownloadServicePicker.show(
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
context,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
trackName: track.name,
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
@@ -182,222 +189,20 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, ref, (quality, service) {
|
DownloadServicePicker.show(
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
context,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
trackName: '${tracks.length} tracks',
|
||||||
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('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
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
|||||||
+977
-397
File diff suppressed because it is too large
Load Diff
@@ -4,17 +4,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class DownloadSettingsPage extends ConsumerWidget {
|
class DownloadSettingsPage extends ConsumerWidget {
|
||||||
const DownloadSettingsPage({super.key});
|
const DownloadSettingsPage({super.key});
|
||||||
|
|
||||||
|
// Built-in services that support quality options
|
||||||
|
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
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(
|
return PopScope(
|
||||||
canPop: true,
|
canPop: true,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@@ -87,13 +94,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
title: 'Ask Before Download',
|
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,
|
value: settings.askQualityBeforeDownload,
|
||||||
|
// Not selected visually if extension is active
|
||||||
|
enabled: isBuiltInService,
|
||||||
onChanged: (value) => ref
|
onChanged: (value) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAskQualityBeforeDownload(value),
|
.setAskQualityBeforeDownload(value),
|
||||||
),
|
),
|
||||||
if (!settings.askQualityBeforeDownload) ...[
|
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: 'FLAC Lossless',
|
title: 'FLAC Lossless',
|
||||||
subtitle: '16-bit / 44.1kHz',
|
subtitle: '16-bit / 44.1kHz',
|
||||||
@@ -120,6 +131,29 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
showDivider: false,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -359,8 +393,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
// Android: Use file picker
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null)
|
if (result != null) {
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,9 +547,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
example: 'SpotiFLAC/Track.flac',
|
example: 'SpotiFLAC/Track.flac',
|
||||||
isSelected: current == 'none',
|
isSelected: current == 'none',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
ref.read(settingsProvider.notifier).setFolderOrganization('none');
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setFolderOrganization('none');
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -524,9 +557,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||||
isSelected: current == 'artist',
|
isSelected: current == 'artist',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setFolderOrganization('artist');
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -536,9 +567,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||||
isSelected: current == 'album',
|
isSelected: current == 'album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
ref.read(settingsProvider.notifier).setFolderOrganization('album');
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setFolderOrganization('album');
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -548,9 +577,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||||
isSelected: current == 'artist_album',
|
isSelected: current == 'artist_album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
|
||||||
.read(settingsProvider.notifier)
|
|
||||||
.setFolderOrganization('artist_album');
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -562,7 +589,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ServiceSelector extends StatelessWidget {
|
class _ServiceSelector extends ConsumerWidget {
|
||||||
final String currentService;
|
final String currentService;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _ServiceSelector({
|
const _ServiceSelector({
|
||||||
@@ -571,31 +598,75 @@ class _ServiceSelector extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_ServiceChip(
|
Row(
|
||||||
icon: Icons.music_note,
|
children: [
|
||||||
label: 'Tidal',
|
_ServiceChip(
|
||||||
isSelected: currentService == 'tidal',
|
icon: Icons.music_note,
|
||||||
onTap: () => onChanged('tidal'),
|
label: 'Tidal',
|
||||||
),
|
isSelected: effectiveService == 'tidal',
|
||||||
const SizedBox(width: 8),
|
onTap: () => onChanged('tidal'),
|
||||||
_ServiceChip(
|
),
|
||||||
icon: Icons.album,
|
const SizedBox(width: 8),
|
||||||
label: 'Qobuz',
|
_ServiceChip(
|
||||||
isSelected: currentService == 'qobuz',
|
icon: Icons.album,
|
||||||
onTap: () => onChanged('qobuz'),
|
label: 'Qobuz',
|
||||||
),
|
isSelected: effectiveService == 'qobuz',
|
||||||
const SizedBox(width: 8),
|
onTap: () => onChanged('qobuz'),
|
||||||
_ServiceChip(
|
),
|
||||||
icon: Icons.shopping_bag,
|
const SizedBox(width: 8),
|
||||||
label: 'Amazon',
|
_ServiceChip(
|
||||||
isSelected: currentService == 'amazon',
|
icon: Icons.shopping_bag,
|
||||||
onTap: () => onChanged('amazon'),
|
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()),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,964 @@
|
|||||||
|
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/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class ExtensionDetailPage extends ConsumerStatefulWidget {
|
||||||
|
final String extensionId;
|
||||||
|
|
||||||
|
const ExtensionDetailPage({super.key, required this.extensionId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ExtensionDetailPage> createState() => _ExtensionDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||||
|
Map<String, dynamic> _settings = {};
|
||||||
|
bool _isLoadingSettings = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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 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),
|
||||||
|
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,
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Search Provider Section (if extension has custom search)
|
||||||
|
if (extension.hasCustomSearch) ...[
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Search Provider'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_SearchProviderInfo(
|
||||||
|
extension: extension,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 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<void> _updateSetting(String key, dynamic value) async {
|
||||||
|
setState(() {
|
||||||
|
_settings[key] = value;
|
||||||
|
});
|
||||||
|
await ref
|
||||||
|
.read(extensionProvider.notifier)
|
||||||
|
.setExtensionSettings(widget.extensionId, _settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmRemove(BuildContext context) async {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Remove Extension'),
|
||||||
|
content: const Text(
|
||||||
|
'Are you sure you want to remove this extension? '
|
||||||
|
'This action cannot be undone.',
|
||||||
|
),
|
||||||
|
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) {
|
||||||
|
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<dynamic> 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<String>(
|
||||||
|
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 _SearchProviderInfo extends StatelessWidget {
|
||||||
|
final Extension extension;
|
||||||
|
|
||||||
|
const _SearchProviderInfo({
|
||||||
|
required this.extension,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final searchBehavior = extension.searchBehavior;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.manage_search,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Custom Search Available',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'This extension provides its own search functionality',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Search placeholder info
|
||||||
|
if (searchBehavior?.placeholder != null) ...[
|
||||||
|
_InfoTile(
|
||||||
|
icon: Icons.text_fields,
|
||||||
|
label: 'Search Hint',
|
||||||
|
value: searchBehavior!.placeholder!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
// Primary search info
|
||||||
|
_InfoTile(
|
||||||
|
icon: searchBehavior?.primary == true ? Icons.star : Icons.star_border,
|
||||||
|
label: 'Priority',
|
||||||
|
value: searchBehavior?.primary == true
|
||||||
|
? 'Primary search provider'
|
||||||
|
: 'Secondary search provider',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Usage instructions
|
||||||
|
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(
|
||||||
|
'To use this search provider, tap the search bar on the Home tab and select "${extension.displayName}" from the provider chips.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const _InfoTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'$label: ',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,721 @@
|
|||||||
|
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<ExtensionsPage> createState() => _ExtensionsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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 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<void> _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<bool> 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<Extension> 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MetadataProviderPriorityPage> createState() => _MetadataProviderPriorityPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderPriorityPage> {
|
||||||
|
late List<String> _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<bool> _confirmDiscard(BuildContext context) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Discard Changes?'),
|
||||||
|
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
||||||
|
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<void> _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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_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';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class OptionsSettingsPage extends ConsumerWidget {
|
class OptionsSettingsPage extends ConsumerWidget {
|
||||||
@@ -11,6 +12,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
|
final extensionState = ref.watch(extensionProvider);
|
||||||
|
final hasExtensions = extensionState.extensions.isNotEmpty;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
@@ -73,38 +76,50 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
.setMetadataSource(v),
|
.setMetadataSource(v),
|
||||||
),
|
),
|
||||||
if (settings.metadataSource == 'spotify') ...[
|
if (settings.metadataSource == 'spotify') ...[
|
||||||
SettingsSwitchItem(
|
// Info card about Spotify credentials requirement
|
||||||
icon: Icons.toggle_on,
|
if (settings.spotifyClientId.isEmpty)
|
||||||
title: 'Use Custom Credentials',
|
Padding(
|
||||||
subtitle: settings.useCustomSpotifyCredentials
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
? 'Using your credentials'
|
child: Card(
|
||||||
: 'Using default credentials',
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
value: settings.useCustomSpotifyCredentials,
|
child: Padding(
|
||||||
onChanged: (v) {
|
padding: const EdgeInsets.all(12),
|
||||||
ref
|
child: Row(
|
||||||
.read(settingsProvider.notifier)
|
children: [
|
||||||
.setUseCustomSpotifyCredentials(v);
|
Icon(
|
||||||
if (v && settings.spotifyClientId.isEmpty) {
|
Icons.warning_amber_rounded,
|
||||||
_showSpotifyCredentialsDialog(context, ref, settings);
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
}
|
),
|
||||||
},
|
const SizedBox(width: 12),
|
||||||
showDivider: true,
|
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(
|
SettingsItem(
|
||||||
icon: Icons.key,
|
icon: Icons.key,
|
||||||
title: 'Set Credentials',
|
title: 'Spotify Credentials',
|
||||||
subtitle: settings.spotifyClientId.isNotEmpty
|
subtitle: settings.spotifyClientId.isNotEmpty
|
||||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||||
: 'Not configured',
|
: 'Required - tap to configure',
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_showSpotifyCredentialsDialog(context, ref, settings),
|
_showSpotifyCredentialsDialog(context, ref, settings),
|
||||||
trailing: Icon(
|
trailing: Icon(
|
||||||
settings.spotifyClientId.isNotEmpty
|
settings.spotifyClientId.isNotEmpty
|
||||||
? Icons.edit
|
? Icons.check_circle
|
||||||
: Icons.add,
|
: Icons.error_outline,
|
||||||
color: settings.spotifyClientId.isNotEmpty
|
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,
|
size: 20,
|
||||||
),
|
),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
@@ -129,6 +144,18 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
ref.read(settingsProvider.notifier).setAutoFallback(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(
|
SettingsSwitchItem(
|
||||||
icon: Icons.lyrics,
|
icon: Icons.lyrics,
|
||||||
title: 'Embed Lyrics',
|
title: 'Embed Lyrics',
|
||||||
@@ -345,11 +372,15 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -380,11 +411,15 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -745,7 +780,7 @@ class _ChannelChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetadataSourceSelector extends StatelessWidget {
|
class _MetadataSourceSelector extends ConsumerWidget {
|
||||||
final String currentSource;
|
final String currentSource;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _MetadataSourceSelector({
|
const _MetadataSourceSelector({
|
||||||
@@ -754,8 +789,25 @@ class _MetadataSourceSelector extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -769,9 +821,13 @@ class _MetadataSourceSelector extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
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(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: hasExtensionSearch
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -780,18 +836,57 @@ class _MetadataSourceSelector extends StatelessWidget {
|
|||||||
_SourceChip(
|
_SourceChip(
|
||||||
icon: Icons.graphic_eq,
|
icon: Icons.graphic_eq,
|
||||||
label: 'Deezer',
|
label: 'Deezer',
|
||||||
isSelected: currentSource == 'deezer',
|
badge: 'Free',
|
||||||
onTap: () => onChanged('deezer'),
|
badgeColor: colorScheme.tertiary,
|
||||||
|
// Not selected if extension is active
|
||||||
|
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
||||||
|
onTap: () {
|
||||||
|
// If extension was active, reset it to default
|
||||||
|
if (hasExtensionSearch) {
|
||||||
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
|
}
|
||||||
|
onChanged('deezer');
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_SourceChip(
|
_SourceChip(
|
||||||
icon: Icons.music_note,
|
icon: Icons.music_note,
|
||||||
label: 'Spotify',
|
label: 'Spotify',
|
||||||
isSelected: currentSource == 'spotify',
|
badge: 'API Key',
|
||||||
onTap: () => onChanged('spotify'),
|
badgeColor: colorScheme.secondary,
|
||||||
|
// Not selected if extension is active
|
||||||
|
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
||||||
|
onTap: () {
|
||||||
|
// If extension was active, reset it to default
|
||||||
|
if (hasExtensionSearch) {
|
||||||
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
|
}
|
||||||
|
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 +897,17 @@ class _SourceChip extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback? onTap;
|
||||||
|
final String? badge;
|
||||||
|
final Color? badgeColor;
|
||||||
|
|
||||||
const _SourceChip({
|
const _SourceChip({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.isSelected,
|
required this.isSelected,
|
||||||
required this.onTap,
|
this.onTap,
|
||||||
|
this.badge,
|
||||||
|
this.badgeColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -854,6 +953,24 @@ class _SourceChip extends StatelessWidget {
|
|||||||
: colorScheme.onSurfaceVariant,
|
: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<ProviderPriorityPage> createState() => _ProviderPriorityPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||||
|
late List<String> _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<bool> _confirmDiscard(BuildContext context) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Discard Changes?'),
|
||||||
|
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
||||||
|
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<void> _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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:spotiflac_android/constants/app_info.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/appearance_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/download_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/options_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||||
@@ -31,7 +32,10 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxHeight = 120 + topPadding;
|
final maxHeight = 120 + topPadding;
|
||||||
final minHeight = kToolbarHeight + 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(
|
return FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
@@ -58,7 +62,8 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
icon: Icons.palette_outlined,
|
icon: Icons.palette_outlined,
|
||||||
title: 'Appearance',
|
title: 'Appearance',
|
||||||
subtitle: 'Theme, colors, display',
|
subtitle: 'Theme, colors, display',
|
||||||
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
onTap: () =>
|
||||||
|
_navigateTo(context, const AppearanceSettingsPage()),
|
||||||
),
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.download_outlined,
|
icon: Icons.download_outlined,
|
||||||
@@ -71,6 +76,12 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
title: 'Options',
|
title: 'Options',
|
||||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.extension_outlined,
|
||||||
|
title: 'Extensions',
|
||||||
|
subtitle: 'Manage download providers',
|
||||||
|
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -380,11 +380,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
_clientIdController.text.trim(),
|
_clientIdController.text.trim(),
|
||||||
_clientSecretController.text.trim(),
|
_clientSecretController.text.trim(),
|
||||||
);
|
);
|
||||||
ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true);
|
// Set search source to Spotify when credentials are provided
|
||||||
// Set search source to Spotify when using custom credentials
|
|
||||||
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||||
} else {
|
} else {
|
||||||
// Use Deezer as default search source
|
// Use Deezer as default search source (free, no credentials required)
|
||||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -331,7 +331,6 @@ class PlatformBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set custom Spotify API credentials
|
/// Set custom Spotify API credentials
|
||||||
/// Pass empty strings to use default credentials
|
|
||||||
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||||
await _channel.invokeMethod('setSpotifyCredentials', {
|
await _channel.invokeMethod('setSpotifyCredentials', {
|
||||||
'client_id': clientId,
|
'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<bool> hasSpotifyCredentials() async {
|
||||||
|
final result = await _channel.invokeMethod('hasSpotifyCredentials');
|
||||||
|
return result as bool;
|
||||||
|
}
|
||||||
|
|
||||||
/// Pre-warm track ID cache for album/playlist tracks
|
/// Pre-warm track ID cache for album/playlist tracks
|
||||||
/// This runs in background and returns immediately
|
/// This runs in background and returns immediately
|
||||||
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
|
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
|
||||||
@@ -439,4 +445,332 @@ class PlatformBridge {
|
|||||||
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
||||||
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== EXTENSION SYSTEM ====================
|
||||||
|
|
||||||
|
/// Initialize the extension system
|
||||||
|
static Future<void> 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<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) async {
|
||||||
|
_log.d('loadExtensionsFromDir: $dirPath');
|
||||||
|
final result = await _channel.invokeMethod('loadExtensionsFromDir', {
|
||||||
|
'dir_path': dirPath,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a single extension from file
|
||||||
|
static Future<Map<String, dynamic>> loadExtensionFromPath(String filePath) async {
|
||||||
|
_log.d('loadExtensionFromPath: $filePath');
|
||||||
|
final result = await _channel.invokeMethod('loadExtensionFromPath', {
|
||||||
|
'file_path': filePath,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unload an extension
|
||||||
|
static Future<void> unloadExtension(String extensionId) async {
|
||||||
|
_log.d('unloadExtension: $extensionId');
|
||||||
|
await _channel.invokeMethod('unloadExtension', {
|
||||||
|
'extension_id': extensionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an extension completely (unload + delete files)
|
||||||
|
static Future<void> 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<Map<String, dynamic>> upgradeExtension(String filePath) async {
|
||||||
|
_log.d('upgradeExtension: $filePath');
|
||||||
|
final result = await _channel.invokeMethod('upgradeExtension', {
|
||||||
|
'file_path': filePath,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a package file is an upgrade for an existing extension
|
||||||
|
static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||||
|
_log.d('checkExtensionUpgrade: $filePath');
|
||||||
|
final result = await _channel.invokeMethod('checkExtensionUpgrade', {
|
||||||
|
'file_path': filePath,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all installed extensions
|
||||||
|
static Future<List<Map<String, dynamic>>> getInstalledExtensions() async {
|
||||||
|
final result = await _channel.invokeMethod('getInstalledExtensions');
|
||||||
|
final list = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable an extension
|
||||||
|
static Future<void> 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<void> setProviderPriority(List<String> providerIds) async {
|
||||||
|
_log.d('setProviderPriority: $providerIds');
|
||||||
|
await _channel.invokeMethod('setProviderPriority', {
|
||||||
|
'priority': jsonEncode(providerIds),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get provider priority order
|
||||||
|
static Future<List<String>> getProviderPriority() async {
|
||||||
|
final result = await _channel.invokeMethod('getProviderPriority');
|
||||||
|
final list = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return list.map((e) => e as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set metadata provider priority order
|
||||||
|
static Future<void> setMetadataProviderPriority(List<String> providerIds) async {
|
||||||
|
_log.d('setMetadataProviderPriority: $providerIds');
|
||||||
|
await _channel.invokeMethod('setMetadataProviderPriority', {
|
||||||
|
'priority': jsonEncode(providerIds),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get metadata provider priority order
|
||||||
|
static Future<List<String>> getMetadataProviderPriority() async {
|
||||||
|
final result = await _channel.invokeMethod('getMetadataProviderPriority');
|
||||||
|
final list = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return list.map((e) => e as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get extension settings
|
||||||
|
static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||||
|
final result = await _channel.invokeMethod('getExtensionSettings', {
|
||||||
|
'extension_id': extensionId,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set extension settings
|
||||||
|
static Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||||
|
_log.d('setExtensionSettings: $extensionId');
|
||||||
|
await _channel.invokeMethod('setExtensionSettings', {
|
||||||
|
'extension_id': extensionId,
|
||||||
|
'settings': jsonEncode(settings),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search tracks using extension providers
|
||||||
|
static Future<List<Map<String, dynamic>>> 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<dynamic>;
|
||||||
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download with extension providers (includes fallback)
|
||||||
|
static Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleanup all extensions (call on app close)
|
||||||
|
static Future<void> cleanupExtensions() async {
|
||||||
|
_log.d('cleanupExtensions');
|
||||||
|
await _channel.invokeMethod('cleanupExtensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EXTENSION AUTH API ====================
|
||||||
|
|
||||||
|
/// Get pending auth request for an extension (if any)
|
||||||
|
static Future<Map<String, dynamic>?> 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set auth code for an extension (after OAuth callback)
|
||||||
|
static Future<void> 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<void> 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<void> clearExtensionPendingAuth(String extensionId) async {
|
||||||
|
await _channel.invokeMethod('clearExtensionPendingAuth', {
|
||||||
|
'extension_id': extensionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if extension is authenticated
|
||||||
|
static Future<bool> 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<List<Map<String, dynamic>>> getAllPendingAuthRequests() async {
|
||||||
|
final result = await _channel.invokeMethod('getAllPendingAuthRequests');
|
||||||
|
final list = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EXTENSION FFMPEG API ====================
|
||||||
|
|
||||||
|
/// Get pending FFmpeg command for execution
|
||||||
|
static Future<Map<String, dynamic>?> 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set FFmpeg command result
|
||||||
|
static Future<void> 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<List<Map<String, dynamic>>> getAllPendingFFmpegCommands() async {
|
||||||
|
final result = await _channel.invokeMethod('getAllPendingFFmpegCommands');
|
||||||
|
final list = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EXTENSION CUSTOM SEARCH ====================
|
||||||
|
|
||||||
|
/// Perform custom search using an extension
|
||||||
|
static Future<List<Map<String, dynamic>>> customSearchWithExtension(
|
||||||
|
String extensionId,
|
||||||
|
String query, {
|
||||||
|
Map<String, dynamic>? 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<dynamic>;
|
||||||
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all extensions that provide custom search
|
||||||
|
static Future<List<Map<String, dynamic>>> getSearchProviders() async {
|
||||||
|
final result = await _channel.invokeMethod('getSearchProviders');
|
||||||
|
final list = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EXTENSION POST-PROCESSING ====================
|
||||||
|
|
||||||
|
/// Run post-processing hooks on a file
|
||||||
|
static Future<Map<String, dynamic>> runPostProcessing(
|
||||||
|
String filePath, {
|
||||||
|
Map<String, dynamic>? metadata,
|
||||||
|
}) async {
|
||||||
|
final result = await _channel.invokeMethod('runPostProcessing', {
|
||||||
|
'file_path': filePath,
|
||||||
|
'metadata': metadata != null ? jsonEncode(metadata) : '',
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all extensions that provide post-processing
|
||||||
|
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
|
||||||
|
final result = await _channel.invokeMethod('getPostProcessingProviders');
|
||||||
|
final list = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<QualityOption> 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<DownloadServicePicker> 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<DownloadServicePicker> {
|
||||||
|
late String _selectedService;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedService = ref.read(settingsProvider).defaultService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get quality options for the selected service
|
||||||
|
List<QualityOption> _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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@ class SettingsSwitchItem extends StatelessWidget {
|
|||||||
final bool value;
|
final bool value;
|
||||||
final ValueChanged<bool>? onChanged;
|
final ValueChanged<bool>? onChanged;
|
||||||
final bool showDivider;
|
final bool showDivider;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
const SettingsSwitchItem({
|
const SettingsSwitchItem({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -142,53 +143,60 @@ class SettingsSwitchItem extends StatelessWidget {
|
|||||||
required this.value,
|
required this.value,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.showDivider = true,
|
this.showDivider = true,
|
||||||
|
this.enabled = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDisabled = !enabled || onChanged == null;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
Opacity(
|
||||||
onTap: onChanged != null ? () => onChanged!(!value) : null,
|
opacity: isDisabled ? 0.5 : 1.0,
|
||||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
child: InkWell(
|
||||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
onTap: isDisabled ? null : () => onChanged!(!value),
|
||||||
child: Padding(
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
child: Row(
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
if (icon != null) ...[
|
child: Row(
|
||||||
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
children: [
|
||||||
const SizedBox(width: 16),
|
if (icon != null) ...[
|
||||||
],
|
Icon(icon, color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, size: 24),
|
||||||
Expanded(
|
const SizedBox(width: 16),
|
||||||
child: Column(
|
],
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Expanded(
|
||||||
children: [
|
child: Column(
|
||||||
Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title,
|
children: [
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
if (subtitle != null) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
Text(
|
||||||
subtitle!,
|
title,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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),
|
||||||
const SizedBox(width: 8),
|
Switch(
|
||||||
Switch(
|
value: value,
|
||||||
value: value,
|
onChanged: isDisabled ? null : onChanged,
|
||||||
onChanged: onChanged,
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.2.7+49
|
version: 3.0.0-alpha.2+51
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.2.7+49
|
version: 3.0.0-alpha.2+51
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user