From 8daff4d0a4a620de810a466c75fb2ff51f3c38d5 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 01:01:43 +0700 Subject: [PATCH] feat: improve Extension Store with custom icons and various fixes - Support custom extension icons from registry (iconUrl field) - Support both camelCase and snake_case in registry JSON - Fix download file extension (.spotiflac-ext) - New extensions start disabled by default - Preserve enabled state on extension upgrade - Add toggle to show/hide Store tab in Settings > Options - Reorder tabs: Home, History, Store, Settings --- go_backend/exports.go | 2 +- go_backend/extension_manager.go | 11 +- go_backend/extension_store.go | 113 ++++++++++++++---- lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/settings_provider.dart | 5 + lib/screens/main_shell.dart | 92 ++++++++------ .../settings/options_settings_page.dart | 9 ++ lib/screens/store_tab.dart | 44 +++++-- 9 files changed, 210 insertions(+), 72 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index cda85cc4..30a07c80 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1770,7 +1770,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { return "", fmt.Errorf("extension store not initialized") } - destPath := fmt.Sprintf("%s/%s.spotiflac", destDir, extensionID) + destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID) err := store.DownloadExtension(extensionID, destPath) if err != nil { return "", err diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 2270b70e..045708a6 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -231,7 +231,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens ext := &LoadedExtension{ ID: manifest.Name, Manifest: manifest, - Enabled: true, + Enabled: false, // New extensions start disabled DataDir: extDataDir, SourceDir: extDir, } @@ -459,7 +459,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx ext := &LoadedExtension{ ID: manifest.Name, Manifest: manifest, - Enabled: true, + Enabled: false, // Will be restored from settings store DataDir: extDataDir, SourceDir: dirPath, } @@ -583,9 +583,10 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, 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) + // Save data directory path and enabled state (we want to preserve them) extDataDir := existing.DataDir extDir := existing.SourceDir + wasEnabled := existing.Enabled // Cleanup and unload existing extension m.CleanupExtension(existing.ID) @@ -633,11 +634,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, } } - // Create new loaded extension (reusing data directory) + // Create new loaded extension (reusing data directory, preserving enabled state) ext := &LoadedExtension{ ID: newManifest.Name, Manifest: newManifest, - Enabled: true, + Enabled: wasEnabled, // Preserve enabled state from before upgrade DataDir: extDataDir, SourceDir: extDir, } diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index ed0da274..2a2e1097 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -24,17 +24,57 @@ const ( type StoreExtension struct { ID string `json:"id"` Name string `json:"name"` - DisplayName string `json:"display_name"` + DisplayName string `json:"display_name,omitempty"` Version string `json:"version"` Author string `json:"author"` Description string `json:"description"` - DownloadURL string `json:"download_url"` + DownloadURL string `json:"download_url,omitempty"` IconURL string `json:"icon_url,omitempty"` Category string `json:"category"` Tags []string `json:"tags,omitempty"` Downloads int `json:"downloads"` UpdatedAt string `json:"updated_at"` MinAppVersion string `json:"min_app_version,omitempty"` + // Alternative camelCase fields (for flexibility) + DisplayNameAlt string `json:"displayName,omitempty"` + DownloadURLAlt string `json:"downloadUrl,omitempty"` + IconURLAlt string `json:"iconUrl,omitempty"` + MinAppVersionAlt string `json:"minAppVersion,omitempty"` +} + +// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict) +func (e *StoreExtension) getDisplayName() string { + if e.DisplayName != "" { + return e.DisplayName + } + if e.DisplayNameAlt != "" { + return e.DisplayNameAlt + } + return e.Name +} + +// getDownloadURL returns download URL from either field (private to avoid gomobile conflict) +func (e *StoreExtension) getDownloadURL() string { + if e.DownloadURL != "" { + return e.DownloadURL + } + return e.DownloadURLAlt +} + +// getIconURL returns icon URL from either field (private to avoid gomobile conflict) +func (e *StoreExtension) getIconURL() string { + if e.IconURL != "" { + return e.IconURL + } + return e.IconURLAlt +} + +// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict) +func (e *StoreExtension) getMinAppVersion() string { + if e.MinAppVersion != "" { + return e.MinAppVersion + } + return e.MinAppVersionAlt } // StoreRegistry represents the extension registry @@ -44,12 +84,43 @@ type StoreRegistry struct { Extensions []StoreExtension `json:"extensions"` } -// StoreExtensionWithStatus adds installation status to StoreExtension -type StoreExtensionWithStatus struct { - StoreExtension - IsInstalled bool `json:"is_installed"` - InstalledVersion string `json:"installed_version,omitempty"` - HasUpdate bool `json:"has_update"` +// StoreExtensionResponse is the normalized response sent to Flutter +type StoreExtensionResponse struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + DownloadURL string `json:"download_url"` + IconURL string `json:"icon_url,omitempty"` + Category string `json:"category"` + Tags []string `json:"tags,omitempty"` + Downloads int `json:"downloads"` + UpdatedAt string `json:"updated_at"` + MinAppVersion string `json:"min_app_version,omitempty"` + IsInstalled bool `json:"is_installed"` + InstalledVersion string `json:"installed_version,omitempty"` + HasUpdate bool `json:"has_update"` +} + +// ToResponse converts StoreExtension to normalized response +func (e *StoreExtension) ToResponse() StoreExtensionResponse { + return StoreExtensionResponse{ + ID: e.ID, + Name: e.Name, + DisplayName: e.getDisplayName(), + Version: e.Version, + Author: e.Author, + Description: e.Description, + DownloadURL: e.getDownloadURL(), + IconURL: e.getIconURL(), + Category: e.Category, + Tags: e.Tags, + Downloads: e.Downloads, + UpdatedAt: e.UpdatedAt, + MinAppVersion: e.getMinAppVersion(), + } } // ExtensionStore manages the extension store @@ -198,7 +269,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error } // GetExtensionsWithStatus returns extensions with installation status -func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionWithStatus, error) { +func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { registry, err := s.FetchRegistry(false) if err != nil { return nil, err @@ -213,19 +284,17 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionWithStatus, } } - result := make([]StoreExtensionWithStatus, len(registry.Extensions)) + result := make([]StoreExtensionResponse, len(registry.Extensions)) for i, ext := range registry.Extensions { - status := StoreExtensionWithStatus{ - StoreExtension: ext, - } + resp := ext.ToResponse() if installedVersion, ok := installed[ext.ID]; ok { - status.IsInstalled = true - status.InstalledVersion = installedVersion - status.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 + resp.IsInstalled = true + resp.InstalledVersion = installedVersion + resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 } - result[i] = status + result[i] = resp } return result, nil @@ -250,10 +319,10 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) return fmt.Errorf("extension %s not found in store", extensionID) } - LogInfo("ExtensionStore", "Downloading %s from %s", ext.DisplayName, ext.DownloadURL) + LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) client := &http.Client{Timeout: 5 * time.Minute} - resp, err := client.Get(ext.DownloadURL) + resp, err := client.Get(ext.getDownloadURL()) if err != nil { return fmt.Errorf("failed to download: %w", err) } @@ -276,7 +345,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) return fmt.Errorf("failed to write file: %w", err) } - LogInfo("ExtensionStore", "Downloaded %s to %s", ext.DisplayName, destPath) + LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath) return nil } @@ -292,7 +361,7 @@ func (s *ExtensionStore) GetCategories() []string { } // SearchExtensions searches extensions by query -func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionWithStatus, error) { +func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { extensions, err := s.GetExtensionsWithStatus() if err != nil { return nil, err @@ -302,7 +371,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor return extensions, nil } - var result []StoreExtensionWithStatus + var result []StoreExtensionResponse queryLower := toLower(query) for _, ext := range extensions { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 0e11a65b..5c6f2b4f 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -28,6 +28,7 @@ class AppSettings { final bool useExtensionProviders; // Use extension providers for downloads when available final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID final bool separateSingles; // Separate singles/EPs into their own folder + final bool showExtensionStore; // Show Extension Store tab in navigation const AppSettings({ this.defaultService = 'tidal', @@ -54,6 +55,7 @@ class AppSettings { this.useExtensionProviders = true, // Default: use extensions when available this.searchProvider, // Default: null (use Deezer/Spotify) this.separateSingles = false, // Default: disabled + this.showExtensionStore = true, // Default: show store }); AppSettings copyWith({ @@ -81,6 +83,7 @@ class AppSettings { bool? useExtensionProviders, String? searchProvider, bool? separateSingles, + bool? showExtensionStore, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -107,6 +110,7 @@ class AppSettings { useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, searchProvider: searchProvider ?? this.searchProvider, separateSingles: separateSingles ?? this.separateSingles, + showExtensionStore: showExtensionStore ?? this.showExtensionStore, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 899ad5c3..bdbb8745 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -32,6 +32,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, searchProvider: json['searchProvider'] as String?, separateSingles: json['separateSingles'] as bool? ?? false, + showExtensionStore: json['showExtensionStore'] as bool? ?? true, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -60,4 +61,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'useExtensionProviders': instance.useExtensionProviders, 'searchProvider': instance.searchProvider, 'separateSingles': instance.separateSingles, + 'showExtensionStore': instance.showExtensionStore, }; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 5251b1e6..f56da477 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -216,6 +216,11 @@ class SettingsNotifier extends Notifier { state = state.copyWith(separateSingles: enabled); _saveSettings(); } + + void setShowExtensionStore(bool enabled) { + state = state.copyWith(showExtensionStore: enabled); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 81156c0a..d2b927da 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -173,6 +173,7 @@ class _MainShellState extends ConsumerState { Widget build(BuildContext context) { final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final trackState = ref.watch(trackProvider); + final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore)); // Check if keyboard is visible (bottom inset > 0 means keyboard is showing) final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; @@ -186,6 +187,57 @@ class _MainShellState extends ConsumerState { !trackState.isLoading && !isKeyboardVisible; + // Build tabs and destinations based on settings + final tabs = [ + const HomeTab(), + const QueueTab(), + if (showStore) const StoreTab(), + const SettingsTab(), + ]; + + final destinations = [ + const NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.history_outlined), + ), + selectedIcon: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.history), + ), + label: 'History', + ), + if (showStore) + const NavigationDestination( + icon: Icon(Icons.store_outlined), + selectedIcon: Icon(Icons.store), + label: 'Store', + ), + const NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ]; + + // Clamp current index if tabs changed + final maxIndex = tabs.length - 1; + if (_currentIndex > maxIndex) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _currentIndex = maxIndex); + _pageController.jumpToPage(maxIndex); + } + }); + } + return PopScope( canPop: canPop, onPopInvokedWithResult: (didPop, result) async { @@ -203,50 +255,16 @@ class _MainShellState extends ConsumerState { controller: _pageController, onPageChanged: _onPageChanged, physics: const BouncingScrollPhysics(), - children: const [ - HomeTab(), - StoreTab(), - QueueTab(), - SettingsTab(), - ], + children: tabs, ), bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, + selectedIndex: _currentIndex.clamp(0, maxIndex), onDestinationSelected: _onNavTap, animationDuration: const Duration(milliseconds: 200), backgroundColor: Theme.of(context).brightness == Brightness.dark ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface) : Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface), - destinations: [ - const NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', - ), - const NavigationDestination( - icon: Icon(Icons.store_outlined), - selectedIcon: Icon(Icons.store), - label: 'Store', - ), - NavigationDestination( - icon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.history_outlined), - ), - selectedIcon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.history), - ), - label: 'History', - ), - const NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', - ), - ], + destinations: destinations, ), ), ); diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 512e612e..06113cbe 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -202,6 +202,15 @@ class OptionsSettingsPage extends ConsumerWidget { SliverToBoxAdapter( child: SettingsGroup( children: [ + SettingsSwitchItem( + icon: Icons.store, + title: 'Extension Store', + subtitle: 'Show Store tab in navigation', + value: settings.showExtensionStore, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setShowExtensionStore(v), + ), SettingsSwitchItem( icon: Icons.system_update, title: 'Check for Updates', diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 6af8497c..000da84a 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -410,7 +410,7 @@ class _ExtensionItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - // Extension icon + // Extension icon - custom or category-based Container( width: 44, height: 44, @@ -420,12 +420,42 @@ class _ExtensionItem extends StatelessWidget { : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), - child: Icon( - _getCategoryIcon(extension.category), - color: extension.isInstalled - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), + clipBehavior: Clip.antiAlias, + child: extension.iconUrl != null && extension.iconUrl!.isNotEmpty + ? Image.network( + extension.iconUrl!, + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + _getCategoryIcon(extension.category), + color: extension.isInstalled + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + ) + : Icon( + _getCategoryIcon(extension.category), + color: extension.isInstalled + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), ), const SizedBox(width: 16), // Extension info