mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 20:57:57 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>?) ??
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -242,8 +242,6 @@ class LibraryDatabase {
|
||||
};
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user