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 50e6eda0..aca778de 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -3064,6 +3064,25 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } + "setStoreRegistryUrl" -> { + val registryUrl = call.argument("registry_url") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setStoreRegistryURLJSON(registryUrl) + } + result.success(null) + } + "getStoreRegistryUrl" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getStoreRegistryURLJSON() + } + result.success(response) + } + "clearStoreRegistryUrl" -> { + withContext(Dispatchers.IO) { + Gobackend.clearStoreRegistryURLJSON() + } + result.success(null) + } "getStoreExtensions" -> { val forceRefresh = call.argument("force_refresh") ?: false val response = withContext(Dispatchers.IO) { diff --git a/go_backend/cover.go b/go_backend/cover.go index fd0ed822..10c89963 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -104,7 +104,6 @@ func upgradeDeezerCover(coverURL string) string { return coverURL } - // Replace any size pattern with 1800x1800 upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg") if upgraded != coverURL { GoLog("[Cover] Deezer: upgraded to 1800x1800") diff --git a/go_backend/exports.go b/go_backend/exports.go index ec757a2d..44fa7831 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2930,6 +2930,45 @@ func InitExtensionStoreJSON(cacheDir string) error { return nil } +func SetStoreRegistryURLJSON(registryURL string) error { + store := GetExtensionStore() + if store == nil { + return fmt.Errorf("extension store not initialized") + } + + resolved, err := ResolveRegistryURL(registryURL) + if err != nil { + return err + } + + if err := requireHTTPSURL(resolved, "registry"); err != nil { + return err + } + + store.SetRegistryURL(resolved) + return nil +} + +func ClearStoreRegistryURLJSON() error { + store := GetExtensionStore() + if store == nil { + return fmt.Errorf("extension store not initialized") + } + + store.SetRegistryURL("") + store.ClearCache() + return nil +} + +func GetStoreRegistryURLJSON() (string, error) { + store := GetExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + return store.GetRegistryURL(), nil +} + func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { store := GetExtensionStore() if store == nil { diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 2fa17e84..90dbf61a 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "sync" "time" ) @@ -129,9 +130,8 @@ var ( ) const ( - defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json" - cacheTTL = 30 * time.Minute - cacheFileName = "store_cache.json" + cacheTTL = 30 * time.Minute + cacheFileName = "store_cache.json" ) func InitExtensionStore(cacheDir string) *ExtensionStore { @@ -140,7 +140,7 @@ func InitExtensionStore(cacheDir string) *ExtensionStore { if extensionStore == nil { extensionStore = &ExtensionStore{ - registryURL: defaultRegistryURL, + registryURL: "", // No default - user must provide a registry URL cacheDir: cacheDir, cacheTTL: cacheTTL, } @@ -149,6 +149,36 @@ func InitExtensionStore(cacheDir string) *ExtensionStore { return extensionStore } +// SetRegistryURL updates the registry URL and clears the in-memory cache +// so the next fetch will use the new URL. Disk cache is also cleared. +func (s *ExtensionStore) SetRegistryURL(registryURL string) { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + if s.registryURL == registryURL { + return + } + + s.registryURL = registryURL + s.cache = nil + s.cacheTime = time.Time{} + + // Clear disk cache since it's from a different registry + if s.cacheDir != "" { + cachePath := filepath.Join(s.cacheDir, cacheFileName) + os.Remove(cachePath) + } + + LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL) +} + +// GetRegistryURL returns the currently configured registry URL. +func (s *ExtensionStore) GetRegistryURL() string { + s.cacheMu.RLock() + defer s.cacheMu.RUnlock() + return s.registryURL +} + func GetExtensionStore() *ExtensionStore { extensionStoreMu.Lock() defer extensionStoreMu.Unlock() @@ -206,6 +236,11 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error s.cacheMu.Lock() defer s.cacheMu.Unlock() + // Check if a registry URL has been configured + if s.registryURL == "" { + return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first") + } + 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 @@ -336,6 +371,81 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) return nil } +// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL. +// +// Accepted formats: +// - https://raw.githubusercontent.com/owner/repo//registry.json → returned as-is +// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via +// the GitHub API to discover the default branch, then converted to the raw URL +// - Any other HTTPS URL → returned as-is (assumed to be a direct link) +func ResolveRegistryURL(input string) (string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", fmt.Errorf("registry URL is empty") + } + + // Already a fully-qualified raw URL – keep it. + if strings.Contains(input, "raw.githubusercontent.com") { + return input, nil + } + + // Try to match https://github.com//[/...] + const ghPrefix = "https://github.com/" + if !strings.HasPrefix(input, ghPrefix) { + // Also accept http:// and upgrade silently. + const ghPrefixHTTP = "http://github.com/" + if strings.HasPrefix(input, ghPrefixHTTP) { + input = "https://github.com/" + input[len(ghPrefixHTTP):] + } else { + // Not a GitHub URL – return as-is. + return input, nil + } + } + + path := input[len(ghPrefix):] + parts := strings.SplitN(path, "/", 3) // owner, repo, [rest] + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("invalid GitHub URL: expected github.com//") + } + owner := parts[0] + repo := strings.TrimSuffix(parts[1], ".git") + + branch := resolveGitHubDefaultBranch(owner, repo) + + resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch) + LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch) + return resolved, nil +} + +// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's +// default branch. Falls back to "main" on any error. +func resolveGitHubDefaultBranch(owner, repo string) string { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo) + client := NewHTTPClientWithTimeout(10 * time.Second) + + resp, err := client.Get(apiURL) + if err != nil { + LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err) + return "main" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo) + return "main" + } + + var info struct { + DefaultBranch string `json:"default_branch"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" { + LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo) + return "main" + } + + return info.DefaultBranch +} + func requireHTTPSURL(rawURL string, context string) error { if rawURL == "" { return fmt.Errorf("%s URL is empty", context) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 586ffbf6..320efb5a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -791,6 +791,23 @@ import Gobackend // Import Go framework if let error = error { throw error } return nil + case "setStoreRegistryUrl": + let args = call.arguments as! [String: Any] + let registryUrl = args["registry_url"] as? String ?? "" + GobackendSetStoreRegistryURLJSON(registryUrl, &error) + if let error = error { throw error } + return nil + + case "getStoreRegistryUrl": + let response = GobackendGetStoreRegistryURLJSON(&error) + if let error = error { throw error } + return response + + case "clearStoreRegistryUrl": + GobackendClearStoreRegistryURLJSON(&error) + if let error = error { throw error } + return nil + case "getStoreExtensions": let args = call.arguments as! [String: Any] let forceRefresh = args["force_refresh"] as? Bool ?? false diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 1dd10dd7..8c36a4f6 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -399,7 +399,6 @@ class LocalLibraryNotifier extends Notifier { return; } - // Parse incremental scan result // SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths' final scannedList = (result['files'] as List?) ?? diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index e967825a..bac55c5c 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -6,6 +7,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; final _log = AppLogger('StoreProvider'); final RegExp _leadingVersionPrefix = RegExp(r'^v'); +const _registryUrlPrefKey = 'store_registry_url'; int compareVersions(String v1, String v2) { final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.'); @@ -125,6 +127,7 @@ class StoreState { final String? downloadingId; final String? error; final bool isInitialized; + final String registryUrl; const StoreState({ this.extensions = const [], @@ -135,8 +138,12 @@ class StoreState { this.downloadingId, this.error, this.isInitialized = false, + this.registryUrl = '', }); + /// Whether a registry URL has been configured by the user. + bool get hasRegistryUrl => registryUrl.isNotEmpty; + StoreState copyWith({ List? extensions, String? selectedCategory, @@ -149,6 +156,7 @@ class StoreState { String? error, bool clearError = false, bool? isInitialized, + String? registryUrl, }) { return StoreState( extensions: extensions ?? this.extensions, @@ -159,6 +167,7 @@ class StoreState { downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId), error: clearError ? null : (error ?? this.error), isInitialized: isInitialized ?? this.isInitialized, + registryUrl: registryUrl ?? this.registryUrl, ); } @@ -201,15 +210,84 @@ class StoreNotifier extends Notifier { try { await PlatformBridge.initExtensionStore(cacheDir); - await refresh(); + + // Load saved registry URL from SharedPreferences + final prefs = await SharedPreferences.getInstance(); + final savedUrl = prefs.getString(_registryUrlPrefKey) ?? ''; + + if (savedUrl.isNotEmpty) { + await PlatformBridge.setStoreRegistryUrl(savedUrl); + state = state.copyWith(registryUrl: savedUrl); + await refresh(); + } + state = state.copyWith(isInitialized: true, isLoading: false); - _log.i('Extension store initialized'); + _log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})'); } catch (e) { _log.e('Failed to initialize store: $e'); state = state.copyWith(isLoading: false, error: e.toString()); } } + /// Sets the registry URL, saves it, and refreshes the store. + /// The Go backend handles URL normalisation (GitHub repo → raw URL, branch detection). + Future setRegistryUrl(String url) async { + final trimmed = url.trim(); + if (trimmed.isEmpty) { + state = state.copyWith(error: 'Please enter a valid URL'); + return; + } + + state = state.copyWith(isLoading: true, clearError: true); + + try { + // Go backend resolves GitHub URLs (detects default branch) and validates HTTPS. + await PlatformBridge.setStoreRegistryUrl(trimmed); + + // Read back the resolved URL (may differ from input after normalisation). + final resolvedUrl = await PlatformBridge.getStoreRegistryUrl(); + + // Persist to SharedPreferences + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_registryUrlPrefKey, resolvedUrl); + + state = state.copyWith( + registryUrl: resolvedUrl, + extensions: const [], // Clear old extensions + ); + + _log.i('Registry URL set to: $resolvedUrl'); + await refresh(forceRefresh: true); + } catch (e) { + _log.e('Failed to set registry URL: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Removes the saved registry URL and fully detaches the repo from backend. + Future removeRegistryUrl() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_registryUrlPrefKey); + + // Reset the URL in Go backend memory AND clear its cache + await PlatformBridge.clearStoreRegistryUrl(); + + state = state.copyWith( + registryUrl: '', + extensions: const [], + clearCategory: true, + searchQuery: '', + clearError: true, + ); + + _log.i('Registry URL removed'); + } catch (e) { + _log.e('Failed to remove registry URL: $e'); + state = state.copyWith(error: e.toString()); + } + } + Future refresh({bool forceRefresh = false}) async { state = state.copyWith(isLoading: true, clearError: true); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 79cb17a8..cf9ceab5 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -158,12 +158,10 @@ class _MainShellState extends ConsumerState { if (settings.storageMode == 'saf') return; if (settings.downloadDirectory.isEmpty) return; - // Check Android version final deviceInfo = DeviceInfoPlugin(); final androidInfo = await deviceInfo.androidInfo; if (androidInfo.version.sdkInt < 29) return; - // Only show once final prefs = await SharedPreferences.getInstance(); if (prefs.getBool(_safMigrationShownKey) == true) return; await prefs.setBool(_safMigrationShownKey, true); diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index ff97c194..54af17db 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -16,6 +16,7 @@ class StoreTab extends ConsumerStatefulWidget { class _StoreTabState extends ConsumerState { final _searchController = TextEditingController(); + final _repoUrlController = TextEditingController(); bool _isInitialized = false; @override @@ -38,6 +39,7 @@ class _StoreTabState extends ConsumerState { @override void dispose() { _searchController.dispose(); + _repoUrlController.dispose(); super.dispose(); } @@ -56,6 +58,8 @@ class _StoreTabState extends ConsumerState { final downloadingId = ref.watch( storeProvider.select((s) => s.downloadingId), ); + final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl)); + final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl)); final filteredExtensions = StoreState( extensions: extensions, selectedCategory: selectedCategory, @@ -84,6 +88,14 @@ class _StoreTabState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, automaticallyImplyLeading: false, + actions: [ + if (hasRegistryUrl) + IconButton( + icon: const Icon(Icons.link), + tooltip: context.l10n.storeChangeRepoTooltip, + onPressed: () => _showChangeRepoDialog(registryUrl), + ), + ], flexibleSpace: LayoutBuilder( builder: (context, constraints) { final maxHeight = 120 + topPadding; @@ -109,169 +121,176 @@ class _StoreTabState extends ConsumerState { ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: ValueListenableBuilder( - valueListenable: _searchController, - builder: (context, value, _) { - return TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.storeSearch, - prefixIcon: const Icon(Icons.search), - suffixIcon: value.text.isNotEmpty - ? IconButton( - tooltip: 'Clear search', - 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); - }, - ); - }, - ), - ), - ), - - SliverToBoxAdapter( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - children: [ - _CategoryChip( - label: context.l10n.storeFilterAll, - icon: Icons.apps, - isSelected: selectedCategory == null, - onTap: () => - ref.read(storeProvider.notifier).setCategory(null), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterMetadata, - icon: Icons.label_outline, - isSelected: selectedCategory == StoreCategory.metadata, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.metadata), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterDownload, - icon: Icons.download_outlined, - isSelected: selectedCategory == StoreCategory.download, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.download), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterUtility, - icon: Icons.build_outlined, - isSelected: selectedCategory == StoreCategory.utility, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.utility), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterLyrics, - icon: Icons.lyrics_outlined, - isSelected: selectedCategory == StoreCategory.lyrics, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.lyrics), - ), - const SizedBox(width: 8), - _CategoryChip( - label: context.l10n.storeFilterIntegration, - icon: Icons.link, - isSelected: selectedCategory == StoreCategory.integration, - onTap: () => ref - .read(storeProvider.notifier) - .setCategory(StoreCategory.integration), - ), - ], - ), - ), - ), - - if (isLoading && extensions.isEmpty) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (error != null && extensions.isEmpty) - SliverFillRemaining(child: _buildErrorState(error, colorScheme)) - else if (filteredExtensions.isEmpty) + // Show setup screen if no registry URL is configured + if (!hasRegistryUrl) SliverFillRemaining( - child: _buildEmptyState( - hasFilters: - searchQuery.isNotEmpty || selectedCategory != null, - colorScheme: colorScheme, - ), + child: _buildSetupRepoState(colorScheme, error), ) else ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - '${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + child: ValueListenableBuilder( + valueListenable: _searchController, + builder: (context, value, _) { + return TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: context.l10n.storeSearch, + prefixIcon: const Icon(Icons.search), + suffixIcon: value.text.isNotEmpty + ? IconButton( + tooltip: 'Clear search', + 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); + }, + ); + }, ), ), ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: SettingsGroup( - children: filteredExtensions.asMap().entries.map((entry) { - final index = entry.key; - final ext = entry.value; - return _ExtensionItem( - extension: ext, - showDivider: index < filteredExtensions.length - 1, - isDownloading: downloadingId == ext.id, - onInstall: () => _installExtension(ext), - onUpdate: () => _updateExtension(ext), - onTap: () => _showExtensionDetails(ext), - ); - }).toList(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + _CategoryChip( + label: context.l10n.storeFilterAll, + icon: Icons.apps, + isSelected: selectedCategory == null, + onTap: () => + ref.read(storeProvider.notifier).setCategory(null), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterMetadata, + icon: Icons.label_outline, + isSelected: selectedCategory == StoreCategory.metadata, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.metadata), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterDownload, + icon: Icons.download_outlined, + isSelected: selectedCategory == StoreCategory.download, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.download), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterUtility, + icon: Icons.build_outlined, + isSelected: selectedCategory == StoreCategory.utility, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.utility), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterLyrics, + icon: Icons.lyrics_outlined, + isSelected: selectedCategory == StoreCategory.lyrics, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.lyrics), + ), + const SizedBox(width: 8), + _CategoryChip( + label: context.l10n.storeFilterIntegration, + icon: Icons.link, + isSelected: selectedCategory == StoreCategory.integration, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.integration), + ), + ], ), ), ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), + if (isLoading && extensions.isEmpty) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (error != null && extensions.isEmpty) + SliverFillRemaining(child: _buildErrorState(error, colorScheme)) + else if (filteredExtensions.isEmpty) + SliverFillRemaining( + child: _buildEmptyState( + hasFilters: + searchQuery.isNotEmpty || selectedCategory != null, + colorScheme: colorScheme, + ), + ) + else ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + '${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SettingsGroup( + children: filteredExtensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < filteredExtensions.length - 1, + isDownloading: downloadingId == ext.id, + onInstall: () => _installExtension(ext), + onUpdate: () => _updateExtension(ext), + onTap: () => _showExtensionDetails(ext), + ); + }).toList(), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], ], ], ), @@ -279,6 +298,166 @@ class _StoreTabState extends ConsumerState { ); } + Widget _buildSetupRepoState(ColorScheme colorScheme, String? error) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.store_outlined, + size: 72, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 24), + Text( + context.l10n.storeAddRepoTitle, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextField( + controller: _repoUrlController, + decoration: InputDecoration( + hintText: context.l10n.storeRepoUrlHint, + labelText: context.l10n.storeRepoUrlLabel, + prefixIcon: const Icon(Icons.link), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + ), + keyboardType: TextInputType.url, + autocorrect: false, + onSubmitted: (_) => _submitRepoUrl(), + ), + if (error != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + error, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _submitRepoUrl, + icon: const Icon(Icons.add), + label: Text(context.l10n.storeAddRepoButton), + ), + ), + ], + ), + ), + ); + } + + void _submitRepoUrl() { + final url = _repoUrlController.text.trim(); + if (url.isEmpty) return; + ref.read(storeProvider.notifier).setRegistryUrl(url); + } + + void _showChangeRepoDialog(String currentUrl) { + final changeUrlController = TextEditingController(text: currentUrl); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.storeRepoDialogTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.storeRepoDialogCurrent, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + currentUrl, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 11, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 16), + TextField( + controller: changeUrlController, + decoration: InputDecoration( + hintText: context.l10n.storeRepoUrlHint, + labelText: context.l10n.storeNewRepoUrlLabel, + prefixIcon: const Icon(Icons.link), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + keyboardType: TextInputType.url, + autocorrect: false, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + ref.read(storeProvider.notifier).removeRegistryUrl(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(context.l10n.dialogRemove), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + final newUrl = changeUrlController.text.trim(); + Navigator.of(context).pop(); + if (newUrl.isNotEmpty) { + ref.read(storeProvider.notifier).setRegistryUrl(newUrl); + } + }, + child: Text(context.l10n.dialogSave), + ), + ], + ), + ).then((_) => changeUrlController.dispose()); + } + Widget _buildErrorState(String error, ColorScheme colorScheme) { return Center( child: Padding( @@ -289,7 +468,7 @@ class _StoreTabState extends ConsumerState { Icon(Icons.error_outline, size: 64, color: colorScheme.error), const SizedBox(height: 16), Text( - 'Failed to load store', + context.l10n.storeLoadError, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), @@ -328,7 +507,7 @@ class _StoreTabState extends ConsumerState { ), const SizedBox(height: 16), Text( - hasFilters ? 'No extensions found' : 'No extensions available', + hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 95f65c44..68ecb60b 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -1596,7 +1596,6 @@ class _TrackMetadataScreenState extends ConsumerState { }); try { - // Convert duration from seconds to milliseconds final durationMs = (duration ?? 0) * 1000; // First, check if lyrics are embedded in the file @@ -1617,7 +1616,6 @@ class _TrackMetadataScreenState extends ConsumerState { final embeddedSource = embeddedResult['source']?.toString() ?? ''; if (embeddedLyrics.isNotEmpty) { - // Lyrics found in file if (mounted) { final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); setState(() { @@ -1650,7 +1648,6 @@ class _TrackMetadataScreenState extends ConsumerState { lrcText == '[instrumental:true]'; if (mounted) { - // Check for instrumental marker if (instrumental) { setState(() { _isInstrumental = true; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index c92fb7a3..703dfe6d 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -242,8 +242,6 @@ class LibraryDatabase { }; } - // CRUD Operations - Future upsert(Map json) async { final db = await database; await db.insert( diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index c41207b9..8ea1dddc 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1191,6 +1191,22 @@ class PlatformBridge { await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); } + static Future setStoreRegistryUrl(String registryUrl) async { + _log.d('setStoreRegistryUrl: $registryUrl'); + await _channel.invokeMethod('setStoreRegistryUrl', {'registry_url': registryUrl}); + } + + static Future getStoreRegistryUrl() async { + _log.d('getStoreRegistryUrl'); + final result = await _channel.invokeMethod('getStoreRegistryUrl'); + return result as String? ?? ''; + } + + static Future clearStoreRegistryUrl() async { + _log.d('clearStoreRegistryUrl'); + await _channel.invokeMethod('clearStoreRegistryUrl'); + } + static Future>> getStoreExtensions({ bool forceRefresh = false, }) async {