diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f52102..7c6bd231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### Added +- **Extension Store**: Browse and install extensions directly from the app + - New "Store" tab in bottom navigation + - Browse extensions by category (Metadata, Download, Utility, Lyrics, Integration) + - Search extensions by name, description, or tags + - One-tap install and update + - Offline cache for browsing without internet + - Extensions hosted at github.com/zarzet/SpotiFLAC-Extension + - **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns - Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc. - Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }` diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index aa777954..6a0fb6b5 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -587,6 +587,49 @@ class MainActivity: FlutterActivity() { } result.success(response) } + // Extension Store + "initExtensionStore" -> { + val cacheDir = call.argument("cache_dir") ?: "" + withContext(Dispatchers.IO) { + Gobackend.initExtensionStoreJSON(cacheDir) + } + result.success(null) + } + "getStoreExtensions" -> { + val forceRefresh = call.argument("force_refresh") ?: false + val response = withContext(Dispatchers.IO) { + Gobackend.getStoreExtensionsJSON(forceRefresh) + } + result.success(response) + } + "searchStoreExtensions" -> { + val query = call.argument("query") ?: "" + val category = call.argument("category") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.searchStoreExtensionsJSON(query, category) + } + result.success(response) + } + "getStoreCategories" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getStoreCategoriesJSON() + } + result.success(response) + } + "downloadStoreExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + val destDir = call.argument("dest_dir") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.downloadStoreExtensionJSON(extensionId, destDir) + } + result.success(response) + } + "clearStoreCache" -> { + withContext(Dispatchers.IO) { + Gobackend.clearStoreCacheJSON() + } + result.success(null) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 1943b566..cda85cc4 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1692,3 +1692,100 @@ func GetPostProcessingProvidersJSON() (string, error) { return string(jsonBytes), nil } + +// ==================== EXTENSION STORE ==================== + +// InitExtensionStoreJSON initializes the extension store with cache directory +func InitExtensionStoreJSON(cacheDir string) error { + InitExtensionStore(cacheDir) + return nil +} + +// GetStoreExtensionsJSON returns all extensions from the store with installation status +func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + // Force refresh if requested + if forceRefresh { + store.FetchRegistry(true) + } + + extensions, err := store.GetExtensionsWithStatus() + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(extensions) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SearchStoreExtensionsJSON searches extensions in the store +func SearchStoreExtensionsJSON(query, category string) (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + extensions, err := store.SearchExtensions(query, category) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(extensions) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetStoreCategoriesJSON returns all available categories +func GetStoreCategoriesJSON() (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + categories := store.GetCategories() + jsonBytes, err := json.Marshal(categories) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// DownloadStoreExtensionJSON downloads an extension from the store +// Returns the path to the downloaded file +func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + destPath := fmt.Sprintf("%s/%s.spotiflac", destDir, extensionID) + err := store.DownloadExtension(extensionID, destPath) + if err != nil { + return "", err + } + + return destPath, nil +} + +// ClearStoreCacheJSON clears the store cache +func ClearStoreCacheJSON() error { + store := GetExtensionStore() + if store == nil { + return fmt.Errorf("extension store not initialized") + } + + store.ClearCache() + return nil +} diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go new file mode 100644 index 00000000..ed0da274 --- /dev/null +++ b/go_backend/extension_store.go @@ -0,0 +1,384 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +// Extension categories +const ( + CategoryMetadata = "metadata" + CategoryDownload = "download" + CategoryUtility = "utility" + CategoryLyrics = "lyrics" + CategoryIntegration = "integration" +) + +// StoreExtension represents an extension in the store +type StoreExtension struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + 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"` +} + +// StoreRegistry represents the extension registry +type StoreRegistry struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` + 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"` +} + +// ExtensionStore manages the extension store +type ExtensionStore struct { + registryURL string + cacheDir string + cache *StoreRegistry + cacheMu sync.RWMutex + cacheTime time.Time + cacheTTL time.Duration +} + +var ( + extensionStore *ExtensionStore + extensionStoreMu sync.Mutex +) + +const ( + defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json" + cacheTTL = 30 * time.Minute + cacheFileName = "store_cache.json" +) + +// InitExtensionStore initializes the extension store +func InitExtensionStore(cacheDir string) *ExtensionStore { + extensionStoreMu.Lock() + defer extensionStoreMu.Unlock() + + if extensionStore == nil { + extensionStore = &ExtensionStore{ + registryURL: defaultRegistryURL, + cacheDir: cacheDir, + cacheTTL: cacheTTL, + } + // Try to load from disk cache + extensionStore.loadDiskCache() + } + return extensionStore +} + +// GetExtensionStore returns the singleton store instance +func GetExtensionStore() *ExtensionStore { + extensionStoreMu.Lock() + defer extensionStoreMu.Unlock() + return extensionStore +} + +// loadDiskCache loads cached registry from disk +func (s *ExtensionStore) loadDiskCache() { + if s.cacheDir == "" { + return + } + + cachePath := filepath.Join(s.cacheDir, cacheFileName) + data, err := os.ReadFile(cachePath) + if err != nil { + return + } + + var cacheData struct { + Registry StoreRegistry `json:"registry"` + CacheTime int64 `json:"cache_time"` + } + + if err := json.Unmarshal(data, &cacheData); err != nil { + return + } + + s.cache = &cacheData.Registry + s.cacheTime = time.Unix(cacheData.CacheTime, 0) + LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) +} + +// saveDiskCache saves registry to disk cache +func (s *ExtensionStore) saveDiskCache() { + if s.cacheDir == "" || s.cache == nil { + return + } + + cacheData := struct { + Registry StoreRegistry `json:"registry"` + CacheTime int64 `json:"cache_time"` + }{ + Registry: *s.cache, + CacheTime: s.cacheTime.Unix(), + } + + data, err := json.Marshal(cacheData) + if err != nil { + return + } + + cachePath := filepath.Join(s.cacheDir, cacheFileName) + os.WriteFile(cachePath, data, 0644) +} + +// FetchRegistry fetches the extension registry from GitHub +func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + // Return cached if valid and not forcing refresh + if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL { + LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions)) + return s.cache, nil + } + + LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(s.registryURL) + if err != nil { + // Return cached data if available on network error + if s.cache != nil { + LogWarn("ExtensionStore", "Network error, using cached registry: %v", err) + return s.cache, nil + } + return nil, fmt.Errorf("failed to fetch registry: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if s.cache != nil { + LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode) + return s.cache, nil + } + return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read registry: %w", err) + } + + var registry StoreRegistry + if err := json.Unmarshal(body, ®istry); err != nil { + return nil, fmt.Errorf("failed to parse registry: %w", err) + } + + s.cache = ®istry + s.cacheTime = time.Now() + s.saveDiskCache() + + LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions)) + return ®istry, nil +} + +// GetExtensionsWithStatus returns extensions with installation status +func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionWithStatus, error) { + registry, err := s.FetchRegistry(false) + if err != nil { + return nil, err + } + + manager := GetExtensionManager() + installed := make(map[string]string) // id -> version + + if manager != nil { + for _, ext := range manager.GetAllExtensions() { + installed[ext.ID] = ext.Manifest.Version + } + } + + result := make([]StoreExtensionWithStatus, len(registry.Extensions)) + for i, ext := range registry.Extensions { + status := StoreExtensionWithStatus{ + StoreExtension: ext, + } + + if installedVersion, ok := installed[ext.ID]; ok { + status.IsInstalled = true + status.InstalledVersion = installedVersion + status.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 + } + + result[i] = status + } + + return result, nil +} + +// DownloadExtension downloads an extension package to the specified path +func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { + registry, err := s.FetchRegistry(false) + if err != nil { + return err + } + + var ext *StoreExtension + for _, e := range registry.Extensions { + if e.ID == extensionID { + ext = &e + break + } + } + + if ext == nil { + return fmt.Errorf("extension %s not found in store", extensionID) + } + + LogInfo("ExtensionStore", "Downloading %s from %s", ext.DisplayName, ext.DownloadURL) + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Get(ext.DownloadURL) + if err != nil { + return fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned HTTP %d", resp.StatusCode) + } + + // Create destination file + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + os.Remove(destPath) + return fmt.Errorf("failed to write file: %w", err) + } + + LogInfo("ExtensionStore", "Downloaded %s to %s", ext.DisplayName, destPath) + return nil +} + +// GetCategories returns all available categories +func (s *ExtensionStore) GetCategories() []string { + return []string{ + CategoryMetadata, + CategoryDownload, + CategoryUtility, + CategoryLyrics, + CategoryIntegration, + } +} + +// SearchExtensions searches extensions by query +func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionWithStatus, error) { + extensions, err := s.GetExtensionsWithStatus() + if err != nil { + return nil, err + } + + if query == "" && category == "" { + return extensions, nil + } + + var result []StoreExtensionWithStatus + queryLower := toLower(query) + + for _, ext := range extensions { + // Filter by category + if category != "" && ext.Category != category { + continue + } + + // Filter by query + if query != "" { + if !containsIgnoreCase(ext.Name, queryLower) && + !containsIgnoreCase(ext.DisplayName, queryLower) && + !containsIgnoreCase(ext.Description, queryLower) && + !containsIgnoreCase(ext.Author, queryLower) { + // Check tags + found := false + for _, tag := range ext.Tags { + if containsIgnoreCase(tag, queryLower) { + found = true + break + } + } + if !found { + continue + } + } + } + + result = append(result, ext) + } + + return result, nil +} + +// ClearCache clears the in-memory and disk cache +func (s *ExtensionStore) ClearCache() { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + s.cache = nil + s.cacheTime = time.Time{} + + if s.cacheDir != "" { + cachePath := filepath.Join(s.cacheDir, cacheFileName) + os.Remove(cachePath) + } + + LogInfo("ExtensionStore", "Cache cleared") +} + +// Helper: case-insensitive contains +func containsIgnoreCase(s, substr string) bool { + return containsStr(toLower(s), substr) +} + +func toLower(s string) string { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + result[i] = c + } + return string(result) +} + +func containsStr(s, substr string) bool { + return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0) +} + +func findSubstring(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart new file mode 100644 index 00000000..eec5e7f6 --- /dev/null +++ b/lib/providers/store_provider.dart @@ -0,0 +1,286 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +final _log = AppLogger('StoreProvider'); + +/// Extension categories +class StoreCategory { + static const String metadata = 'metadata'; + static const String download = 'download'; + static const String utility = 'utility'; + static const String lyrics = 'lyrics'; + static const String integration = 'integration'; + + static const List all = [metadata, download, utility, lyrics, integration]; + + static String getDisplayName(String category) { + switch (category) { + case metadata: + return 'Metadata'; + case download: + return 'Download'; + case utility: + return 'Utility'; + case lyrics: + return 'Lyrics'; + case integration: + return 'Integration'; + default: + return category; + } + } +} + +/// Represents an extension in the store +class StoreExtension { + final String id; + final String name; + final String displayName; + final String version; + final String author; + final String description; + final String downloadUrl; + final String? iconUrl; + final String category; + final List tags; + final int downloads; + final String updatedAt; + final String? minAppVersion; + final bool isInstalled; + final String? installedVersion; + final bool hasUpdate; + + const StoreExtension({ + required this.id, + required this.name, + required this.displayName, + required this.version, + required this.author, + required this.description, + required this.downloadUrl, + this.iconUrl, + required this.category, + this.tags = const [], + this.downloads = 0, + required this.updatedAt, + this.minAppVersion, + this.isInstalled = false, + this.installedVersion, + this.hasUpdate = false, + }); + + factory StoreExtension.fromJson(Map json) { + return StoreExtension( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', + version: json['version'] as String? ?? '0.0.0', + author: json['author'] as String? ?? 'Unknown', + description: json['description'] as String? ?? '', + downloadUrl: json['download_url'] as String? ?? '', + iconUrl: json['icon_url'] as String?, + category: json['category'] as String? ?? 'utility', + tags: (json['tags'] as List?)?.cast() ?? [], + downloads: json['downloads'] as int? ?? 0, + updatedAt: json['updated_at'] as String? ?? '', + minAppVersion: json['min_app_version'] as String?, + isInstalled: json['is_installed'] as bool? ?? false, + installedVersion: json['installed_version'] as String?, + hasUpdate: json['has_update'] as bool? ?? false, + ); + } +} + +/// State for extension store +class StoreState { + final List extensions; + final String? selectedCategory; + final String searchQuery; + final bool isLoading; + final bool isDownloading; + final String? downloadingId; + final String? error; + final bool isInitialized; + + const StoreState({ + this.extensions = const [], + this.selectedCategory, + this.searchQuery = '', + this.isLoading = false, + this.isDownloading = false, + this.downloadingId, + this.error, + this.isInitialized = false, + }); + + StoreState copyWith({ + List? extensions, + String? selectedCategory, + bool clearCategory = false, + String? searchQuery, + bool? isLoading, + bool? isDownloading, + String? downloadingId, + bool clearDownloadingId = false, + String? error, + bool clearError = false, + bool? isInitialized, + }) { + return StoreState( + extensions: extensions ?? this.extensions, + selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory), + searchQuery: searchQuery ?? this.searchQuery, + isLoading: isLoading ?? this.isLoading, + isDownloading: isDownloading ?? this.isDownloading, + downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId), + error: clearError ? null : (error ?? this.error), + isInitialized: isInitialized ?? this.isInitialized, + ); + } + + /// Get filtered extensions based on category and search + List get filteredExtensions { + var result = extensions; + + if (selectedCategory != null) { + result = result.where((e) => e.category == selectedCategory).toList(); + } + + if (searchQuery.isNotEmpty) { + final query = searchQuery.toLowerCase(); + result = result.where((e) => + e.name.toLowerCase().contains(query) || + e.displayName.toLowerCase().contains(query) || + e.description.toLowerCase().contains(query) || + e.author.toLowerCase().contains(query) || + e.tags.any((t) => t.toLowerCase().contains(query)) + ).toList(); + } + + return result; + } +} + +/// Provider for managing extension store +class StoreNotifier extends Notifier { + @override + StoreState build() { + return const StoreState(); + } + + /// Initialize the store + Future initialize(String cacheDir) async { + if (state.isInitialized) return; + + state = state.copyWith(isLoading: true, clearError: true); + + try { + await PlatformBridge.initExtensionStore(cacheDir); + await refresh(); + state = state.copyWith(isInitialized: true, isLoading: false); + _log.i('Extension store initialized'); + } catch (e) { + _log.e('Failed to initialize store: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Refresh extensions from store + Future refresh({bool forceRefresh = false}) async { + state = state.copyWith(isLoading: true, clearError: true); + + try { + final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh); + state = state.copyWith( + extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(), + isLoading: false, + ); + _log.d('Loaded ${state.extensions.length} extensions from store'); + } catch (e) { + _log.e('Failed to refresh store: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Set category filter + void setCategory(String? category) { + if (category == null) { + state = state.copyWith(clearCategory: true); + } else { + state = state.copyWith(selectedCategory: category); + } + } + + /// Set search query + void setSearchQuery(String query) { + state = state.copyWith(searchQuery: query); + } + + /// Clear search + void clearSearch() { + state = state.copyWith(searchQuery: '', clearCategory: true); + } + + /// Download and install extension + Future installExtension(String extensionId, String tempDir, String extensionsDir) async { + state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); + + try { + _log.i('Downloading extension: $extensionId'); + final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); + + _log.i('Installing extension from: $downloadPath'); + final extNotifier = ref.read(extensionProvider.notifier); + final success = await extNotifier.installExtension(downloadPath); + + if (success) { + _log.i('Extension installed: $extensionId'); + await refresh(); + } + + state = state.copyWith(isDownloading: false, clearDownloadingId: true); + return success; + } catch (e) { + _log.e('Failed to install extension: $e'); + state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); + return false; + } + } + + /// Update an installed extension + Future updateExtension(String extensionId, String tempDir) async { + state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); + + try { + _log.i('Downloading update for: $extensionId'); + final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); + + _log.i('Upgrading extension from: $downloadPath'); + final extNotifier = ref.read(extensionProvider.notifier); + final success = await extNotifier.upgradeExtension(downloadPath); + + if (success) { + _log.i('Extension updated: $extensionId'); + await refresh(); + } + + state = state.copyWith(isDownloading: false, clearDownloadingId: true); + return success; + } catch (e) { + _log.e('Failed to update extension: $e'); + state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); + return false; + } + } + + /// Clear error + void clearError() { + state = state.copyWith(clearError: true); + } +} + +final storeProvider = NotifierProvider( + StoreNotifier.new, +); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 92b016c0..81156c0a 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/screens/home_tab.dart'; +import 'package:spotiflac_android/screens/store_tab.dart'; import 'package:spotiflac_android/screens/queue_tab.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; @@ -204,6 +205,7 @@ class _MainShellState extends ConsumerState { physics: const BouncingScrollPhysics(), children: const [ HomeTab(), + StoreTab(), QueueTab(), SettingsTab(), ], @@ -221,6 +223,11 @@ class _MainShellState extends ConsumerState { 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, diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart new file mode 100644 index 00000000..6af8497c --- /dev/null +++ b/lib/screens/store_tab.dart @@ -0,0 +1,537 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/providers/store_provider.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class StoreTab extends ConsumerStatefulWidget { + const StoreTab({super.key}); + + @override + ConsumerState createState() => _StoreTabState(); +} + +class _StoreTabState extends ConsumerState { + final _searchController = TextEditingController(); + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _initialize()); + } + + Future _initialize() async { + if (_isInitialized) return; + _isInitialized = true; + + final cacheDir = await getApplicationCacheDirectory(); + await ref.read(storeProvider.notifier).initialize(cacheDir.path); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(storeProvider); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return Scaffold( + body: RefreshIndicator( + onRefresh: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true), + child: CustomScrollView( + slivers: [ + // App Bar - consistent with other tabs + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + automaticallyImplyLeading: false, + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: const EdgeInsets.only(left: 24, bottom: 16), + title: Text( + 'Store', + style: TextStyle( + fontSize: 20 + (14 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // Search Bar + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search extensions...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + ref.read(storeProvider.notifier).setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Theme.of(context).brightness == Brightness.dark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + : colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + onChanged: (value) { + ref.read(storeProvider.notifier).setSearchQuery(value); + setState(() {}); // Update suffix icon + }, + ), + ), + ), + + // Category Chips + SliverToBoxAdapter( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + _CategoryChip( + label: 'All', + icon: Icons.apps, + isSelected: state.selectedCategory == null, + onTap: () => ref.read(storeProvider.notifier).setCategory(null), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Metadata', + icon: Icons.label_outline, + isSelected: state.selectedCategory == StoreCategory.metadata, + onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.metadata), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Download', + icon: Icons.download_outlined, + isSelected: state.selectedCategory == StoreCategory.download, + onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.download), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Utility', + icon: Icons.build_outlined, + isSelected: state.selectedCategory == StoreCategory.utility, + onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.utility), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Lyrics', + icon: Icons.lyrics_outlined, + isSelected: state.selectedCategory == StoreCategory.lyrics, + onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.lyrics), + ), + const SizedBox(width: 8), + _CategoryChip( + label: 'Integration', + icon: Icons.link, + isSelected: state.selectedCategory == StoreCategory.integration, + onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.integration), + ), + ], + ), + ), + ), + + // Content + if (state.isLoading && state.extensions.isEmpty) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (state.error != null && state.extensions.isEmpty) + SliverFillRemaining( + child: _buildErrorState(state.error!, colorScheme), + ) + else if (state.filteredExtensions.isEmpty) + SliverFillRemaining( + child: _buildEmptyState(state, colorScheme), + ) + else ...[ + // Extensions count + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + '${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + // Extensions list in grouped card (like queue_tab) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SettingsGroup( + children: state.filteredExtensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < state.filteredExtensions.length - 1, + isDownloading: state.downloadingId == ext.id, + onInstall: () => _installExtension(ext), + onUpdate: () => _updateExtension(ext), + ); + }).toList(), + ), + ), + ), + + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ], + ), + ), + ); + } + + Widget _buildErrorState(String error, ColorScheme colorScheme) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 64, color: colorScheme.error), + const SizedBox(height: 16), + Text( + 'Failed to load store', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + error, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) { + final hasFilters = state.searchQuery.isNotEmpty || state.selectedCategory != null; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasFilters ? Icons.search_off : Icons.extension_off, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + hasFilters ? 'No extensions found' : 'No extensions available', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (hasFilters) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: () { + _searchController.clear(); + ref.read(storeProvider.notifier).clearSearch(); + }, + child: const Text('Clear filters'), + ), + ], + ], + ), + ); + } + + Future _installExtension(StoreExtension ext) async { + final tempDir = await getTemporaryDirectory(); + final appDir = await getApplicationDocumentsDirectory(); + final extensionsDir = '${appDir.path}/extensions'; + + final success = await ref.read(storeProvider.notifier).installExtension( + ext.id, + tempDir.path, + extensionsDir, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? '${ext.displayName} installed. Enable it in Settings > Extensions' + : 'Failed to install ${ext.displayName}'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _updateExtension(StoreExtension ext) async { + final tempDir = await getTemporaryDirectory(); + + final success = await ref.read(storeProvider.notifier).updateExtension( + ext.id, + tempDir.path, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? '${ext.displayName} updated to v${ext.version}' + : 'Failed to update ${ext.displayName}'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } +} + + +class _CategoryChip extends StatelessWidget { + final String label; + final IconData icon; + final bool isSelected; + final VoidCallback onTap; + + const _CategoryChip({ + required this.label, + required this.icon, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16), + const SizedBox(width: 6), + Text(label), + ], + ), + selected: isSelected, + onSelected: (_) => onTap(), + showCheckmark: false, + ); + } +} + +class _ExtensionItem extends StatelessWidget { + final StoreExtension extension; + final bool showDivider; + final bool isDownloading; + final VoidCallback onInstall; + final VoidCallback onUpdate; + + const _ExtensionItem({ + required this.extension, + required this.showDivider, + required this.isDownloading, + required this.onInstall, + required this.onUpdate, + }); + + IconData _getCategoryIcon(String category) { + switch (category) { + case StoreCategory.metadata: + return Icons.label_outline; + case StoreCategory.download: + return Icons.download_outlined; + case StoreCategory.utility: + return Icons.build_outlined; + case StoreCategory.lyrics: + return Icons.lyrics_outlined; + case StoreCategory.integration: + return Icons.link; + default: + return Icons.extension; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Extension icon + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: extension.isInstalled + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _getCategoryIcon(extension.category), + color: extension.isInstalled + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 16), + // Extension info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + extension.displayName, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + // Version badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'v${extension.version}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + 'by ${extension.author}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + extension.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + // Action button + if (isDownloading) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (extension.hasUpdate) + FilledButton.tonal( + onPressed: onUpdate, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + ), + child: const Text('Update'), + ) + else if (extension.isInstalled) + OutlinedButton( + onPressed: null, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check, size: 16, color: colorScheme.outline), + const SizedBox(width: 4), + Text('Installed', style: TextStyle(color: colorScheme.outline)), + ], + ), + ) + else + FilledButton( + onPressed: onInstall, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + ), + child: const Text('Install'), + ), + ], + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 76, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 7810006c..43a21fb5 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -807,4 +807,56 @@ class PlatformBridge { final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } + + // ==================== EXTENSION STORE ==================== + + /// Initialize extension store + static Future initExtensionStore(String cacheDir) async { + _log.d('initExtensionStore: $cacheDir'); + await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); + } + + /// Get all extensions from store with installation status + static Future>> getStoreExtensions({bool forceRefresh = false}) async { + _log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); + final result = await _channel.invokeMethod('getStoreExtensions', { + 'force_refresh': forceRefresh, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Search extensions in store + static Future>> searchStoreExtensions(String query, {String? category}) async { + _log.d('searchStoreExtensions: "$query" (category: $category)'); + final result = await _channel.invokeMethod('searchStoreExtensions', { + 'query': query, + 'category': category ?? '', + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Get store categories + static Future> getStoreCategories() async { + final result = await _channel.invokeMethod('getStoreCategories'); + final list = jsonDecode(result as String) as List; + return list.cast(); + } + + /// Download extension from store + static Future downloadStoreExtension(String extensionId, String destDir) async { + _log.i('downloadStoreExtension: $extensionId to $destDir'); + final result = await _channel.invokeMethod('downloadStoreExtension', { + 'extension_id': extensionId, + 'dest_dir': destDir, + }); + return result as String; + } + + /// Clear store cache + static Future clearStoreCache() async { + _log.d('clearStoreCache'); + await _channel.invokeMethod('clearStoreCache'); + } }