mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-10 00:23:58 +02:00
feat: add extension system with skipBuiltInFallback support
- Add extension manager, manifest, runtime, providers, settings - Add extension provider and UI pages (extensions, detail, priority) - Add download service picker widget - Add metadata provider priority page - Add source field to Track model for extension tracking - Add skipBuiltInFallback manifest option to skip built-in providers - Update download queue to use source extension first - Add extension upgrade support without data loss
This commit is contained in:
@@ -13,6 +13,9 @@ Thumbs.db
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
# Documentation (development only, published separately)
|
||||
docs/
|
||||
|
||||
# Old spotiflac_android folder (moved to root)
|
||||
spotiflac_android/
|
||||
|
||||
|
||||
@@ -1,5 +1,98 @@
|
||||
# Changelog
|
||||
|
||||
## [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.7] - 2026-01-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -40,6 +40,31 @@ To use Spotify as your search source without hitting rate limits:
|
||||
4. Enter your Client ID and Secret
|
||||
5. Change **Search Source** to Spotify
|
||||
|
||||
## Extensions (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
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
|
||||
@@ -317,6 +317,249 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
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()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -173,7 +173,7 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||
// This uses submit → poll → download mechanism
|
||||
// Internal function - not exported to gomobile
|
||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) {
|
||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) {
|
||||
var lastError error
|
||||
|
||||
for _, region := range a.regions {
|
||||
|
||||
@@ -135,6 +135,7 @@ type DownloadRequest struct {
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
|
||||
}
|
||||
|
||||
// DownloadResponse represents the result of a download
|
||||
@@ -152,10 +153,14 @@ type DownloadResponse struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
// If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata)
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadResult is a generic result type for all downloaders
|
||||
@@ -1016,3 +1021,520 @@ func errorResponse(msg string) (string, error) {
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION SYSTEM ====================
|
||||
|
||||
// InitExtensionSystem initializes the extension system with directories
|
||||
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
||||
manager := GetExtensionManager()
|
||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsStore := GetExtensionSettingsStore()
|
||||
if err := settingsStore.SetDataDir(dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadExtensionsFromDir loads all extensions from a directory
|
||||
func LoadExtensionsFromDir(dirPath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"loaded": loaded,
|
||||
"errors": make([]string, len(errors)),
|
||||
}
|
||||
|
||||
for i, err := range errors {
|
||||
result["errors"].([]string)[i] = err.Error()
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// LoadExtensionFromPath loads a single extension from a .spotiflac-ext file
|
||||
func LoadExtensionFromPath(filePath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.LoadExtensionFromFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Initialize with saved settings
|
||||
settingsStore := GetExtensionSettingsStore()
|
||||
settings := settingsStore.GetAll(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
manager.InitializeExtension(ext.ID, settings)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": ext.ID,
|
||||
"name": ext.Manifest.Name,
|
||||
"display_name": ext.Manifest.DisplayName,
|
||||
"version": ext.Manifest.Version,
|
||||
"enabled": ext.Enabled,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// UnloadExtensionByID unloads an extension
|
||||
func UnloadExtensionByID(extensionID string) error {
|
||||
manager := GetExtensionManager()
|
||||
return manager.UnloadExtension(extensionID)
|
||||
}
|
||||
|
||||
// RemoveExtensionByID completely removes an extension (unload + delete files)
|
||||
func RemoveExtensionByID(extensionID string) error {
|
||||
manager := GetExtensionManager()
|
||||
return manager.RemoveExtension(extensionID)
|
||||
}
|
||||
|
||||
// UpgradeExtensionFromPath upgrades an existing extension from a new package file
|
||||
func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.UpgradeExtension(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Initialize with saved settings
|
||||
settingsStore := GetExtensionSettingsStore()
|
||||
settings := settingsStore.GetAll(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
manager.InitializeExtension(ext.ID, settings)
|
||||
}
|
||||
|
||||
// Return extension info as JSON
|
||||
result := map[string]interface{}{
|
||||
"id": ext.ID,
|
||||
"display_name": ext.Manifest.DisplayName,
|
||||
"version": ext.Manifest.Version,
|
||||
"enabled": ext.Enabled,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CheckExtensionUpgradeFromPath checks if a package file is an upgrade for an existing extension
|
||||
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
return manager.CheckExtensionUpgradeJSON(filePath)
|
||||
}
|
||||
|
||||
// GetInstalledExtensions returns all installed extensions as JSON
|
||||
func GetInstalledExtensions() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
return manager.GetInstalledExtensionsJSON()
|
||||
}
|
||||
|
||||
// SetExtensionEnabledByID enables or disables an extension
|
||||
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
|
||||
manager := GetExtensionManager()
|
||||
return manager.SetExtensionEnabled(extensionID, enabled)
|
||||
}
|
||||
|
||||
// SetProviderPriorityJSON sets the provider priority order from JSON array
|
||||
func SetProviderPriorityJSON(priorityJSON string) error {
|
||||
var priority []string
|
||||
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetProviderPriority(priority)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProviderPriorityJSON returns the provider priority order as JSON
|
||||
func GetProviderPriorityJSON() (string, error) {
|
||||
priority := GetProviderPriority()
|
||||
jsonBytes, err := json.Marshal(priority)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetMetadataProviderPriorityJSON sets the metadata provider priority order from JSON array
|
||||
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
||||
var priority []string
|
||||
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetMetadataProviderPriority(priority)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadataProviderPriorityJSON returns the metadata provider priority order as JSON
|
||||
func GetMetadataProviderPriorityJSON() (string, error) {
|
||||
priority := GetMetadataProviderPriority()
|
||||
jsonBytes, err := json.Marshal(priority)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetExtensionSettingsJSON returns settings for an extension as JSON
|
||||
func GetExtensionSettingsJSON(extensionID string) (string, error) {
|
||||
store := GetExtensionSettingsStore()
|
||||
settings := store.GetAll(extensionID)
|
||||
|
||||
jsonBytes, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetExtensionSettingsJSON sets settings for an extension from JSON
|
||||
func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store := GetExtensionSettingsStore()
|
||||
if err := store.SetAll(extensionID, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Re-initialize extension with new settings
|
||||
manager := GetExtensionManager()
|
||||
return manager.InitializeExtension(extensionID, settings)
|
||||
}
|
||||
|
||||
// SearchTracksWithExtensionsJSON searches all extension metadata providers
|
||||
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithExtensions(query, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(tracks)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// DownloadWithExtensionsJSON downloads using extension providers with fallback
|
||||
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return "", fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
result, err := DownloadWithExtensionFallback(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CleanupExtensions unloads all extensions gracefully
|
||||
func CleanupExtensions() {
|
||||
manager := GetExtensionManager()
|
||||
manager.UnloadAllExtensions()
|
||||
}
|
||||
|
||||
// ==================== EXTENSION AUTH API ====================
|
||||
|
||||
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||
req := GetPendingAuthRequest(extensionID)
|
||||
if req == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"extension_id": req.ExtensionID,
|
||||
"auth_url": req.AuthURL,
|
||||
"callback_url": req.CallbackURL,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetExtensionAuthCodeByID sets auth code for an extension (called from Flutter after OAuth callback)
|
||||
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||
SetExtensionAuthCode(extensionID, authCode)
|
||||
}
|
||||
|
||||
// SetExtensionTokensByID sets tokens for an extension
|
||||
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
||||
var expiresAt time.Time
|
||||
if expiresIn > 0 {
|
||||
expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt)
|
||||
}
|
||||
|
||||
// ClearExtensionPendingAuthByID clears pending auth request for an extension
|
||||
func ClearExtensionPendingAuthByID(extensionID string) {
|
||||
ClearPendingAuthRequest(extensionID)
|
||||
}
|
||||
|
||||
// IsExtensionAuthenticatedByID checks if an extension is authenticated
|
||||
func IsExtensionAuthenticatedByID(extensionID string) bool {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[extensionID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
return state.IsAuthenticated
|
||||
}
|
||||
|
||||
// GetAllPendingAuthRequestsJSON returns all pending auth requests
|
||||
func GetAllPendingAuthRequestsJSON() (string, error) {
|
||||
pendingAuthRequestsMu.RLock()
|
||||
defer pendingAuthRequestsMu.RUnlock()
|
||||
|
||||
requests := make([]map[string]interface{}, 0, len(pendingAuthRequests))
|
||||
for _, req := range pendingAuthRequests {
|
||||
requests = append(requests, map[string]interface{}{
|
||||
"extension_id": req.ExtensionID,
|
||||
"auth_url": req.AuthURL,
|
||||
"callback_url": req.CallbackURL,
|
||||
})
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(requests)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION FFMPEG API ====================
|
||||
|
||||
// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute
|
||||
func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
|
||||
cmd := GetPendingFFmpegCommand(commandID)
|
||||
if cmd == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"extension_id": cmd.ExtensionID,
|
||||
"command": cmd.Command,
|
||||
"input_path": cmd.InputPath,
|
||||
"output_path": cmd.OutputPath,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetFFmpegCommandResultByID sets the result of an FFmpeg command
|
||||
func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) {
|
||||
SetFFmpegCommandResult(commandID, success, output, errorMsg)
|
||||
}
|
||||
|
||||
// GetAllPendingFFmpegCommandsJSON returns all pending FFmpeg commands
|
||||
func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
||||
ffmpegCommandsMu.RLock()
|
||||
defer ffmpegCommandsMu.RUnlock()
|
||||
|
||||
commands := make([]map[string]interface{}, 0)
|
||||
for cmdID, cmd := range ffmpegCommands {
|
||||
if !cmd.Completed {
|
||||
commands = append(commands, map[string]interface{}{
|
||||
"command_id": cmdID,
|
||||
"extension_id": cmd.ExtensionID,
|
||||
"command": cmd.Command,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(commands)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION CUSTOM SEARCH ====================
|
||||
|
||||
// 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,970 @@
|
||||
// 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])
|
||||
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,
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/go-flac/flacpicture v0.3.0
|
||||
github.com/go-flac/flacvorbis v0.2.0
|
||||
github.com/go-flac/go-flac v1.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
||||
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
||||
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
+55
-56
@@ -22,12 +22,12 @@ import (
|
||||
func getRandomUserAgent() string {
|
||||
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
||||
// Some APIs may block mobile User-Agents, so we use desktop format
|
||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
||||
|
||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
||||
|
||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
||||
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
||||
|
||||
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||
winMajor,
|
||||
@@ -39,46 +39,48 @@ func getRandomUserAgent() string {
|
||||
|
||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||
func getRandomMacUserAgent() string {
|
||||
macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||
macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||
webkitMajor := rand.Intn(7) + 530
|
||||
webkitMinor := rand.Intn(7) + 30
|
||||
chromeMajor := rand.Intn(25) + 80
|
||||
chromeBuild := rand.Intn(1500) + 3000
|
||||
chromePatch := rand.Intn(65) + 60
|
||||
safariMajor := rand.Intn(7) + 530
|
||||
safariMinor := rand.Intn(6) + 30
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
macMajor,
|
||||
macMinor,
|
||||
webkitMajor,
|
||||
webkitMinor,
|
||||
chromeMajor,
|
||||
chromeBuild,
|
||||
chromePatch,
|
||||
safariMajor,
|
||||
safariMinor,
|
||||
)
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func getRandomMacUserAgent() string {
|
||||
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||
// webkitMajor := rand.Intn(7) + 530
|
||||
// webkitMinor := rand.Intn(7) + 30
|
||||
// chromeMajor := rand.Intn(25) + 80
|
||||
// chromeBuild := rand.Intn(1500) + 3000
|
||||
// chromePatch := rand.Intn(65) + 60
|
||||
// safariMajor := rand.Intn(7) + 530
|
||||
// safariMinor := rand.Intn(6) + 30
|
||||
//
|
||||
// return fmt.Sprintf(
|
||||
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
// macMajor,
|
||||
// macMinor,
|
||||
// webkitMajor,
|
||||
// webkitMinor,
|
||||
// chromeMajor,
|
||||
// chromeBuild,
|
||||
// chromePatch,
|
||||
// safariMajor,
|
||||
// safariMinor,
|
||||
// )
|
||||
// }
|
||||
|
||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||
func getRandomDesktopUserAgent() string {
|
||||
if rand.Intn(2) == 0 {
|
||||
return getRandomUserAgent() // Windows
|
||||
}
|
||||
return getRandomMacUserAgent() // Mac
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func getRandomDesktopUserAgent() string {
|
||||
// if rand.Intn(2) == 0 {
|
||||
// return getRandomUserAgent() // Windows
|
||||
// }
|
||||
// return getRandomMacUserAgent() // Mac
|
||||
// }
|
||||
|
||||
// Default timeout values
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
||||
DefaultMaxRetries = 3 // Default retry count
|
||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
||||
DefaultMaxRetries = 3 // Default retry count
|
||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||
)
|
||||
|
||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||
@@ -96,9 +98,9 @@ var sharedTransport = &http.Transport{
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||
DisableCompression: true, // FLAC is already compressed
|
||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||
DisableCompression: true, // FLAC is already compressed
|
||||
}
|
||||
|
||||
// Shared HTTP client for general requests (reuses connections)
|
||||
@@ -184,15 +186,15 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
resp, err := client.Do(reqCopy)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
|
||||
// Check for ISP blocking on network errors
|
||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||
// Don't retry if ISP blocking is detected - it won't help
|
||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||
}
|
||||
|
||||
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||
attempt+1, config.MaxRetries+1, err, delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
@@ -227,13 +229,13 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
|
||||
|
||||
// Check if response looks like ISP blocking page
|
||||
ispBlockingIndicators := []string{
|
||||
"blocked", "forbidden", "access denied", "not available in your",
|
||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||
}
|
||||
|
||||
|
||||
for _, indicator := range ispBlockingIndicators {
|
||||
if strings.Contains(bodyStr, indicator) {
|
||||
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
||||
@@ -267,10 +269,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
// calculateNextDelay calculates the next delay with exponential backoff
|
||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||
if nextDelay > config.MaxDelay {
|
||||
nextDelay = config.MaxDelay
|
||||
}
|
||||
return nextDelay
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
||||
@@ -481,7 +480,7 @@ func extractDomain(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
// Try to extract domain manually
|
||||
@@ -492,7 +491,7 @@ func extractDomain(rawURL string) string {
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
|
||||
if parsed.Host != "" {
|
||||
return parsed.Host
|
||||
}
|
||||
@@ -505,11 +504,11 @@ func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
||||
domain := extractDomain(requestURL)
|
||||
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
|
||||
}
|
||||
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
+24
-23
@@ -250,29 +250,30 @@ func msToLRCTimestamp(ms int64) string {
|
||||
|
||||
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||
// Use convertToLRCWithMetadata for full LRC with headers
|
||||
func convertToLRC(lyrics *LyricsResponse) string {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
for _, line := range lyrics.Lines {
|
||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
builder.WriteString(timestamp)
|
||||
builder.WriteString(line.Words)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
for _, line := range lyrics.Lines {
|
||||
builder.WriteString(line.Words)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||
// if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
// return ""
|
||||
// }
|
||||
//
|
||||
// var builder strings.Builder
|
||||
//
|
||||
// if lyrics.SyncType == "LINE_SYNCED" {
|
||||
// for _, line := range lyrics.Lines {
|
||||
// timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
// builder.WriteString(timestamp)
|
||||
// builder.WriteString(line.Words)
|
||||
// builder.WriteString("\n")
|
||||
// }
|
||||
// } else {
|
||||
// for _, line := range lyrics.Lines {
|
||||
// builder.WriteString(line.Words)
|
||||
// builder.WriteString("\n")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return builder.String()
|
||||
// }
|
||||
|
||||
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||
// Includes [ti:], [ar:], [by:] headers
|
||||
|
||||
@@ -233,7 +233,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||
}
|
||||
|
||||
func preWarmTidalCache(isrc, trackName, artistName string) {
|
||||
func preWarmTidalCache(isrc, _, _ string) {
|
||||
downloader := NewTidalDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
@@ -272,7 +272,7 @@ func PreWarmCache(tracksJSON string) error {
|
||||
var requests []PreWarmCacheRequest
|
||||
// Parse JSON (simplified - in production use proper JSON parsing)
|
||||
// For now, this is called from exports.go with proper parsing
|
||||
|
||||
|
||||
go PreWarmTrackCache(requests) // Run in background
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type ItemProgress struct {
|
||||
ItemID string `json:"item_id"`
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
@@ -204,11 +204,12 @@ func setDownloadDir(path string) error {
|
||||
}
|
||||
|
||||
// getDownloadDir returns the default download directory
|
||||
func getDownloadDir() string {
|
||||
downloadDirMu.RLock()
|
||||
defer downloadDirMu.RUnlock()
|
||||
return downloadDir
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func getDownloadDir() string {
|
||||
// downloadDirMu.RLock()
|
||||
// defer downloadDirMu.RUnlock()
|
||||
// return downloadDir
|
||||
// }
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
type ItemProgressWriter struct {
|
||||
@@ -256,7 +257,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
bytesInInterval := pw.current - pw.lastBytes
|
||||
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
|
||||
}
|
||||
|
||||
|
||||
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
|
||||
pw.lastReported = pw.current
|
||||
pw.lastTime = now
|
||||
|
||||
+9
-8
@@ -271,14 +271,15 @@ func qobuzIsLatinScript(s string) bool {
|
||||
}
|
||||
|
||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||
func qobuzIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func qobuzIsASCIIString(s string) bool {
|
||||
// for _, r := range s {
|
||||
// if r > 127 {
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
|
||||
// containsQueryQobuz checks if a query already exists in the list
|
||||
func containsQueryQobuz(queries []string, query string) bool {
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
|
||||
func normalizeTitle(title string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(title))
|
||||
|
||||
// Remove common suffixes in parentheses or brackets
|
||||
suffixPatterns := []string{
|
||||
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||
" (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||
}
|
||||
for _, suffix := range suffixPatterns {
|
||||
normalized = strings.TrimSuffix(normalized, suffix)
|
||||
}
|
||||
|
||||
// Remove multiple spaces
|
||||
for strings.Contains(normalized, " ") {
|
||||
normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
// normalizeTitle normalizes a track title for comparison
|
||||
// Kept for potential future use
|
||||
// func normalizeTitle(title string) string {
|
||||
// normalized := strings.ToLower(strings.TrimSpace(title))
|
||||
//
|
||||
// // Remove common suffixes in parentheses or brackets
|
||||
// suffixPatterns := []string{
|
||||
// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||
// " (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||
// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||
// }
|
||||
// for _, suffix := range suffixPatterns {
|
||||
// normalized = strings.TrimSuffix(normalized, suffix)
|
||||
// }
|
||||
//
|
||||
// // Remove multiple spaces
|
||||
// for strings.Contains(normalized, " ") {
|
||||
// normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||
// }
|
||||
//
|
||||
// return normalized
|
||||
// }
|
||||
|
||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||
@@ -639,151 +640,20 @@ type TidalDownloadInfo struct {
|
||||
}
|
||||
|
||||
// tidalAPIResult holds the result from a parallel API request
|
||||
type tidalAPIResult struct {
|
||||
apiURL string
|
||||
info TidalDownloadInfo
|
||||
err error
|
||||
duration time.Duration
|
||||
}
|
||||
// Kept for potential future use with _getDownloadURLParallel
|
||||
// type tidalAPIResult struct {
|
||||
// apiURL string
|
||||
// info TidalDownloadInfo
|
||||
// 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)
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Kept for potential future use - currently using sequential approach
|
||||
// func _getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
// ... implementation commented out ...
|
||||
// }
|
||||
|
||||
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
|
||||
// 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
|
||||
func isASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Kept for potential future use
|
||||
// func isASCIIString(s string) bool {
|
||||
// for _, r := range s {
|
||||
// if r > 127 {
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
|
||||
// downloadFromTidal downloads a track using the request parameters
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
@@ -281,6 +281,221 @@ import Gobackend // Import Go framework
|
||||
GobackendSetLoggingEnabled(enabled)
|
||||
return nil
|
||||
|
||||
// Extension System methods
|
||||
case "initExtensionSystem":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionsDir = args["extensions_dir"] as! String
|
||||
let dataDir = args["data_dir"] as! String
|
||||
GobackendInitExtensionSystem(extensionsDir, dataDir, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "loadExtensionsFromDir":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let dirPath = args["dir_path"] as! String
|
||||
let response = GobackendLoadExtensionsFromDir(dirPath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "loadExtensionFromPath":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let response = GobackendLoadExtensionFromPath(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "unloadExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
GobackendUnloadExtensionByID(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getInstalledExtensions":
|
||||
let response = GobackendGetInstalledExtensions(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setExtensionEnabled":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let enabled = args["enabled"] as? Bool ?? false
|
||||
GobackendSetExtensionEnabledByID(extensionId, enabled, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "setProviderPriority":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let priorityJson = args["priority"] as! String
|
||||
GobackendSetProviderPriorityJSON(priorityJson, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getProviderPriority":
|
||||
let response = GobackendGetProviderPriorityJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setMetadataProviderPriority":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let priorityJson = args["priority"] as! String
|
||||
GobackendSetMetadataProviderPriorityJSON(priorityJson, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getMetadataProviderPriority":
|
||||
let response = GobackendGetMetadataProviderPriorityJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getExtensionSettings":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionSettingsJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setExtensionSettings":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let settingsJson = args["settings"] as! String
|
||||
GobackendSetExtensionSettingsJSON(extensionId, settingsJson, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "searchTracksWithExtensions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let limit = args["limit"] as? Int ?? 20
|
||||
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "downloadWithExtensions":
|
||||
let requestJson = call.arguments as! String
|
||||
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "removeExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
GobackendRemoveExtensionByID(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "upgradeExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let response = GobackendUpgradeExtensionFromPath(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "checkExtensionUpgrade":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let response = GobackendCheckExtensionUpgradeFromPath(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "cleanupExtensions":
|
||||
GobackendCleanupExtensions()
|
||||
return nil
|
||||
|
||||
// Extension Auth API
|
||||
case "getExtensionPendingAuth":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendGetExtensionPendingAuthJSON(extensionId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setExtensionAuthCode":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let authCode = args["auth_code"] as! String
|
||||
GobackendSetExtensionAuthCode(extensionId, authCode, &error)
|
||||
if let error = error { throw error }
|
||||
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
|
||||
GobackendSetExtensionTokens(extensionId, accessToken, refreshToken, Int(expiresIn), &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "clearExtensionPendingAuth":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
GobackendClearExtensionPendingAuth(extensionId)
|
||||
return nil
|
||||
|
||||
case "isExtensionAuthenticated":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let response = GobackendIsExtensionAuthenticated(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:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '2.2.7';
|
||||
static const String buildNumber = '49';
|
||||
static const String version = '3.0.0-alpha.1';
|
||||
static const String buildNumber = '50';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ class AppSettings {
|
||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||
final bool enableLogging; // Enable detailed logging for debugging
|
||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -46,6 +48,8 @@ class AppSettings {
|
||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||
this.enableLogging = false, // Default: disabled for performance
|
||||
this.useExtensionProviders = true, // Default: use extensions when available
|
||||
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -69,6 +73,8 @@ class AppSettings {
|
||||
bool? useCustomSpotifyCredentials,
|
||||
String? metadataSource,
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -91,6 +97,8 @@ class AppSettings {
|
||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||
metadataSource: metadataSource ?? this.metadataSource,
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||
searchProvider: searchProvider ?? this.searchProvider,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -52,4 +54,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
'metadataSource': instance.metadataSource,
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ class Track {
|
||||
final String? releaseDate;
|
||||
final String? deezerId;
|
||||
final ServiceAvailability? availability;
|
||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
@@ -33,10 +34,14 @@ class Track {
|
||||
this.releaseDate,
|
||||
this.deezerId,
|
||||
this.availability,
|
||||
this.source,
|
||||
});
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
/// Check if this track is from an extension
|
||||
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
|
||||
@@ -24,6 +24,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
: ServiceAvailability.fromJson(
|
||||
json['availability'] as Map<String, dynamic>,
|
||||
),
|
||||
source: json['source'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
@@ -40,6 +41,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'releaseDate': instance.releaseDate,
|
||||
'deezerId': instance.deezerId,
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
};
|
||||
|
||||
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/track.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
@@ -831,6 +832,56 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_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
|
||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||
// Download cover first
|
||||
@@ -1282,7 +1333,37 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
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(
|
||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||
@@ -1502,6 +1583,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
filePath: filePath,
|
||||
);
|
||||
|
||||
// Run post-processing hooks if enabled
|
||||
if (filePath != null) {
|
||||
await _runPostProcessingHooks(filePath, trackToDownload);
|
||||
}
|
||||
|
||||
// Increment completed counter
|
||||
_completedInSession++;
|
||||
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.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');
|
||||
|
||||
// Update local state
|
||||
final extensions = state.extensions.map((ext) {
|
||||
if (ext.id == extensionId) {
|
||||
return ext.copyWith(enabled: enabled);
|
||||
}
|
||||
return ext;
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
} 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,
|
||||
);
|
||||
@@ -192,12 +192,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSearchProvider(String? provider) {
|
||||
state = state.copyWith(searchProvider: provider);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEnableLogging(bool enabled) {
|
||||
state = state.copyWith(enableLogging: enabled);
|
||||
_saveSettings();
|
||||
// Sync logging state to LogBuffer
|
||||
LogBuffer.loggingEnabled = enabled;
|
||||
}
|
||||
|
||||
void setUseExtensionProviders(bool enabled) {
|
||||
state = state.copyWith(useExtensionProviders: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
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/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('TrackProvider');
|
||||
|
||||
@@ -18,6 +20,7 @@ class TrackState {
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final bool hasSearchText; // For back button handling
|
||||
final String? searchExtensionId; // Extension ID used for current search results
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -32,6 +35,7 @@ class TrackState {
|
||||
this.artistAlbums,
|
||||
this.searchArtists,
|
||||
this.hasSearchText = false,
|
||||
this.searchExtensionId,
|
||||
});
|
||||
|
||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||
@@ -49,6 +53,7 @@ class TrackState {
|
||||
List<ArtistAlbum>? artistAlbums,
|
||||
List<SearchArtist>? searchArtists,
|
||||
bool? hasSearchText,
|
||||
String? searchExtensionId,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
@@ -63,6 +68,7 @@ class TrackState {
|
||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||
searchArtists: searchArtists ?? this.searchArtists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
searchExtensionId: searchExtensionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -210,12 +216,43 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
// Check if extension providers should be used for search
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
final useExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
// Use Deezer or Spotify based on settings
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
_log.i('Search started: source=$source, query="$query"');
|
||||
_log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions');
|
||||
|
||||
Map<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') {
|
||||
_log.d('Calling Deezer search API...');
|
||||
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
|
||||
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++) {
|
||||
final t = trackList[i];
|
||||
try {
|
||||
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 {
|
||||
_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(
|
||||
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 {
|
||||
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
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
@@ -366,6 +465,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+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/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Simple in-memory cache for album tracks
|
||||
class _AlbumCache {
|
||||
@@ -316,10 +317,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
void _downloadTrack(BuildContext context, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
@@ -331,84 +338,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
if (tracks == null || tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: widget.albumName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
@@ -473,148 +417,6 @@ class _AlbumScreenState extends ConsumerState<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
|
||||
class _AlbumTrackItem extends ConsumerWidget {
|
||||
final Track track;
|
||||
|
||||
+85
-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/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
class HomeTab extends ConsumerStatefulWidget {
|
||||
const HomeTab({super.key});
|
||||
@@ -78,12 +80,21 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
// Skip if same query already searched
|
||||
if (_lastSearchQuery == query) return;
|
||||
_lastSearchQuery = query;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
final 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();
|
||||
}
|
||||
|
||||
@@ -173,10 +184,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
@@ -184,107 +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 {
|
||||
// Show loading dialog with progress
|
||||
int currentProgress = 0;
|
||||
int totalTracks = 0;
|
||||
|
||||
// Use StatefulBuilder to update dialog content
|
||||
final dialogContext = context;
|
||||
bool dialogShown = false;
|
||||
StateSetter? setDialogState;
|
||||
|
||||
void showProgressDialog() {
|
||||
if (dialogShown) return;
|
||||
if (dialogShown || !mounted) return;
|
||||
dialogShown = true;
|
||||
showDialog(
|
||||
context: dialogContext,
|
||||
context: this.context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
builder: (dialogCtx) => StatefulBuilder(
|
||||
builder: (dialogCtx, setState) {
|
||||
setDialogState = setState;
|
||||
return AlertDialog(
|
||||
content: Column(
|
||||
@@ -318,25 +251,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
// Close progress dialog
|
||||
if (dialogShown && mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
Navigator.of(this.context).pop();
|
||||
}
|
||||
|
||||
if (tracks.isNotEmpty) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Optionally show confirmation dialog
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
context: this.context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Text('Import Playlist'),
|
||||
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
onPressed: () => Navigator.pop(dialogCtx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
onPressed: () => Navigator.pop(dialogCtx, true),
|
||||
child: const Text('Import'),
|
||||
),
|
||||
],
|
||||
@@ -346,7 +281,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
if (confirmed == true) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Added ${tracks.length} tracks to queue'),
|
||||
action: SnackBarAction(
|
||||
@@ -836,6 +771,22 @@ 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;
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull;
|
||||
if (ext?.searchBehavior?.placeholder != null) {
|
||||
return ext!.searchBehavior!.placeholder!;
|
||||
}
|
||||
return 'Search with ${ext?.displayName ?? 'extension'}...';
|
||||
}
|
||||
return 'Paste Spotify URL or search...';
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(ColorScheme colorScheme) {
|
||||
final hasText = _urlController.text.isNotEmpty;
|
||||
|
||||
@@ -844,7 +795,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
focusNode: _searchFocusNode,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Paste Spotify URL or search...',
|
||||
hintText: _getSearchHint(),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
@@ -910,147 +861,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
|
||||
class _TrackItemWithStatus extends ConsumerWidget {
|
||||
final Track track;
|
||||
@@ -1080,6 +890,28 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
// Get thumbnail size from extension if track is from extension
|
||||
double thumbWidth = 56;
|
||||
double thumbHeight = 56;
|
||||
|
||||
// Get extension ID from track.source or from TrackState.searchExtensionId
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final extensionId = track.source ?? trackState.searchExtensionId;
|
||||
|
||||
if (extensionId != null && extensionId.isNotEmpty) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final extension = extState.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
if (extension?.searchBehavior != null) {
|
||||
final size = extension!.searchBehavior!.getThumbnailSize(defaultSize: 56);
|
||||
thumbWidth = size.$1;
|
||||
thumbHeight = size.$2;
|
||||
// Debug: log only when using custom size
|
||||
if (thumbWidth != 56 || thumbHeight != 56) {
|
||||
debugPrint('[Thumbnail] ${track.name}: using ${thumbWidth.toInt()}x${thumbHeight.toInt()} from ${extension.id}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final isQueued = queueItem != null;
|
||||
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||
@@ -1100,21 +932,21 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album art
|
||||
// Album art with dynamic size based on extension config
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: track.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
memCacheWidth: (thumbWidth * 2).toInt(),
|
||||
memCacheHeight: (thumbHeight * 2).toInt(),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: thumbWidth,
|
||||
height: thumbHeight,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
@@ -1151,7 +983,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 80,
|
||||
indent: thumbWidth + 24, // Adjust divider indent based on thumbnail width
|
||||
endIndent: 12,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
/// Playlist detail screen with Material Expressive 3 design
|
||||
class PlaylistScreen extends ConsumerWidget {
|
||||
@@ -168,10 +169,16 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, ref, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
@@ -182,222 +189,20 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
if (tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, ref, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))),
|
||||
// Disclaimer
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityOption extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoHeader extends StatefulWidget {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||
}
|
||||
|
||||
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||
bool _expanded = false;
|
||||
bool _isOverflowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.coverUrl != null
|
||||
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _isOverflowing != titleOverflows) {
|
||||
setState(() => _isOverflowing = titleOverflows);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.trackName,
|
||||
style: titleStyle,
|
||||
maxLines: _expanded ? 10 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.artistName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.artistName!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: _expanded ? 3 : 1,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_isOverflowing || _expanded)
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
|
||||
@@ -8,12 +8,18 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerWidget {
|
||||
const DownloadSettingsPage({super.key});
|
||||
|
||||
// Built-in services that support quality options
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
// Check if current service is built-in (supports quality options)
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
@@ -87,13 +93,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.tune,
|
||||
title: 'Ask Before Download',
|
||||
subtitle: 'Choose quality for each download',
|
||||
subtitle: isBuiltInService
|
||||
? 'Choose quality for each download'
|
||||
: 'Select a built-in service to enable',
|
||||
value: settings.askQualityBeforeDownload,
|
||||
// Not selected visually if extension is active
|
||||
enabled: isBuiltInService,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAskQualityBeforeDownload(value),
|
||||
),
|
||||
if (!settings.askQualityBeforeDownload) ...[
|
||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||
_QualityOption(
|
||||
title: 'FLAC Lossless',
|
||||
subtitle: '16-bit / 44.1kHz',
|
||||
@@ -120,6 +130,29 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
if (!isBuiltInService) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Select Tidal, Qobuz, or Amazon above to configure quality',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -359,8 +392,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
} else {
|
||||
// Android: Use file picker
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null)
|
||||
if (result != null) {
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class OptionsSettingsPage extends ConsumerWidget {
|
||||
@@ -11,6 +12,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
final hasExtensions = extensionState.extensions.isNotEmpty;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
@@ -129,6 +132,18 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||
),
|
||||
if (hasExtensions)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.extension,
|
||||
title: 'Use Extension Providers',
|
||||
subtitle: settings.useExtensionProviders
|
||||
? 'Extensions will be tried first'
|
||||
: 'Using built-in providers only',
|
||||
value: settings.useExtensionProviders,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseExtensionProviders(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.lyrics,
|
||||
title: 'Embed Lyrics',
|
||||
@@ -345,11 +360,15 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -380,11 +399,15 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -745,7 +768,7 @@ class _ChannelChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataSourceSelector extends StatelessWidget {
|
||||
class _MetadataSourceSelector extends ConsumerWidget {
|
||||
final String currentSource;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _MetadataSourceSelector({
|
||||
@@ -754,8 +777,21 @@ class _MetadataSourceSelector extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
// Check if extension search provider is active
|
||||
final hasExtensionSearch = settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty;
|
||||
|
||||
String? extensionName;
|
||||
if (hasExtensionSearch) {
|
||||
final ext = extState.extensions.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||
extensionName = ext?.displayName ?? settings.searchProvider;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -769,9 +805,13 @@ class _MetadataSourceSelector extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Service used when searching by track name.',
|
||||
hasExtensionSearch
|
||||
? 'Using extension: $extensionName'
|
||||
: 'Service used when searching by track name.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
color: hasExtensionSearch
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -780,18 +820,53 @@ class _MetadataSourceSelector extends StatelessWidget {
|
||||
_SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: 'Deezer',
|
||||
isSelected: currentSource == 'deezer',
|
||||
onTap: () => onChanged('deezer'),
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
// If extension was active, reset it to default
|
||||
if (hasExtensionSearch) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
onChanged('deezer');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SourceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Spotify',
|
||||
isSelected: currentSource == 'spotify',
|
||||
onTap: () => onChanged('spotify'),
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
// If extension was active, reset it to default
|
||||
if (hasExtensionSearch) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
onChanged('spotify');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasExtensionSearch) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tap Deezer or Spotify to switch back from extension',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -802,13 +877,13 @@ class _SourceChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SourceChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
@@ -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/screens/settings/appearance_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||
@@ -31,8 +32,11 @@ class SettingsTab extends ConsumerWidget {
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
@@ -58,7 +62,8 @@ class SettingsTab extends ConsumerWidget {
|
||||
icon: Icons.palette_outlined,
|
||||
title: 'Appearance',
|
||||
subtitle: 'Theme, colors, display',
|
||||
onTap: () => _navigateTo(context, const AppearanceSettingsPage()),
|
||||
onTap: () =>
|
||||
_navigateTo(context, const AppearanceSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.download_outlined,
|
||||
@@ -71,6 +76,12 @@ class SettingsTab extends ConsumerWidget {
|
||||
title: 'Options',
|
||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.extension_outlined,
|
||||
title: 'Extensions',
|
||||
subtitle: 'Manage download providers',
|
||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -97,7 +108,7 @@ class SettingsTab extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Fill remaining space
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
],
|
||||
|
||||
@@ -439,4 +439,332 @@ class PlatformBridge {
|
||||
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
||||
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 ValueChanged<bool>? onChanged;
|
||||
final bool showDivider;
|
||||
final bool enabled;
|
||||
|
||||
const SettingsSwitchItem({
|
||||
super.key,
|
||||
@@ -142,53 +143,60 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
required this.value,
|
||||
this.onChanged,
|
||||
this.showDivider = true,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDisabled = !enabled || onChanged == null;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onChanged != null ? () => onChanged!(!value) : null,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Opacity(
|
||||
opacity: isDisabled ? 0.5 : 1.0,
|
||||
child: InkWell(
|
||||
onTap: isDisabled ? null : () => onChanged!(!value),
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: isDisabled ? colorScheme.outline : null,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: isDisabled ? null : onChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 2.2.7+49
|
||||
version: 3.0.0-alpha.1+50
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 2.2.7+49
|
||||
version: 3.0.0-alpha.1+50
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user