feat: add Extension Store for browsing and installing extensions

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