feat: add store registry URL management, port iOS handlers, and clean up store UI

Add set/get/clear store registry URL method channel handlers on Android,
iOS, and Go backend so users can configure a custom extension repository.

Store tab now shows a setup screen when no registry URL is configured,
with a cleaner layout (removed redundant description and helper text)
and visible TextField borders for dark theme.

Minor comment and formatting cleanups across several files.
This commit is contained in:
zarzet
2026-03-14 13:24:30 +07:00
parent 48f614359e
commit 34d22f783c
12 changed files with 614 additions and 165 deletions
@@ -3064,6 +3064,25 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"setStoreRegistryUrl" -> {
val registryUrl = call.argument<String>("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<Boolean>("force_refresh") ?: false
val response = withContext(Dispatchers.IO) {
-1
View File
@@ -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")
+39
View File
@@ -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 {
+114 -4
View File
@@ -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/<branch>/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/<owner>/<repo>[/...]
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>/<repo>")
}
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)
+17
View File
@@ -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
@@ -399,7 +399,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return;
}
// Parse incremental scan result
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList =
(result['files'] as List<dynamic>?) ??
+80 -2
View File
@@ -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<StoreExtension>? 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<StoreState> {
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<void> 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<void> 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<void> refresh({bool forceRefresh = false}) async {
state = state.copyWith(isLoading: true, clearError: true);
-2
View File
@@ -158,12 +158,10 @@ class _MainShellState extends ConsumerState<MainShell> {
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);
+329 -150
View File
@@ -16,6 +16,7 @@ class StoreTab extends ConsumerStatefulWidget {
class _StoreTabState extends ConsumerState<StoreTab> {
final _searchController = TextEditingController();
final _repoUrlController = TextEditingController();
bool _isInitialized = false;
@override
@@ -38,6 +39,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
@override
void dispose() {
_searchController.dispose();
_repoUrlController.dispose();
super.dispose();
}
@@ -56,6 +58,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
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<StoreTab> {
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<StoreTab> {
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: ValueListenableBuilder<TextEditingValue>(
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<TextEditingValue>(
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<StoreTab> {
);
}
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<StoreTab> {
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<StoreTab> {
),
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,
),
-3
View File
@@ -1596,7 +1596,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
});
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
lrcText == '[instrumental:true]';
if (mounted) {
// Check for instrumental marker
if (instrumental) {
setState(() {
_isInstrumental = true;
-2
View File
@@ -242,8 +242,6 @@ class LibraryDatabase {
};
}
// CRUD Operations
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
+16
View File
@@ -1191,6 +1191,22 @@ class PlatformBridge {
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
}
static Future<void> setStoreRegistryUrl(String registryUrl) async {
_log.d('setStoreRegistryUrl: $registryUrl');
await _channel.invokeMethod('setStoreRegistryUrl', {'registry_url': registryUrl});
}
static Future<String> getStoreRegistryUrl() async {
_log.d('getStoreRegistryUrl');
final result = await _channel.invokeMethod('getStoreRegistryUrl');
return result as String? ?? '';
}
static Future<void> clearStoreRegistryUrl() async {
_log.d('clearStoreRegistryUrl');
await _channel.invokeMethod('clearStoreRegistryUrl');
}
static Future<List<Map<String, dynamic>>> getStoreExtensions({
bool forceRefresh = false,
}) async {