fix: store URL input flash on startup and FLAC metadata fallback for mismatched files

Load saved registry URL before first state update to prevent brief
flash of the setup screen when the store tab initializes.

Add Ogg/Opus fallback in readFileMetadata when FLAC parsing fails,
handling files saved with .flac extension that contain opus data.
This commit is contained in:
zarzet 2026-03-26 16:26:14 +07:00
parent 5e1cc3ecb5
commit bf0f4bdf3e
2 changed files with 126 additions and 54 deletions

View file

@ -698,29 +698,57 @@ func ReadFileMetadata(filePath string) (string, error) {
if isFlac { if isFlac {
metadata, err := ReadMetadata(filePath) metadata, err := ReadMetadata(filePath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err) // File may have wrong extension (e.g. opus saved as .flac).
} // Try Ogg/Opus parser as fallback before giving up.
result["title"] = metadata.Title GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
result["artist"] = metadata.Artist oggMeta, oggErr := ReadOggVorbisComments(filePath)
result["album"] = metadata.Album if oggErr == nil && oggMeta != nil {
result["album_artist"] = metadata.AlbumArtist result["title"] = oggMeta.Title
result["date"] = metadata.Date result["artist"] = oggMeta.Artist
result["track_number"] = metadata.TrackNumber result["album"] = oggMeta.Album
result["disc_number"] = metadata.DiscNumber result["album_artist"] = oggMeta.AlbumArtist
result["isrc"] = metadata.ISRC result["date"] = oggMeta.Date
result["lyrics"] = metadata.Lyrics if oggMeta.Date == "" {
result["genre"] = metadata.Genre result["date"] = oggMeta.Year
result["label"] = metadata.Label }
result["copyright"] = metadata.Copyright result["track_number"] = oggMeta.TrackNumber
result["composer"] = metadata.Composer result["disc_number"] = oggMeta.DiscNumber
result["comment"] = metadata.Comment result["isrc"] = oggMeta.ISRC
result["lyrics"] = oggMeta.Lyrics
result["genre"] = oggMeta.Genre
result["composer"] = oggMeta.Composer
result["comment"] = oggMeta.Comment
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
} else {
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["disc_number"] = metadata.DiscNumber
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
quality, qualityErr := GetAudioQuality(filePath) quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil { if qualityErr == nil {
result["bit_depth"] = quality.BitDepth result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 { if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
} }
} }
} else if isM4A { } else if isM4A {

View file

@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url';
int compareVersions(String v1, String v2) { int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.'); final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.'); final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length; final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
for (var i = 0; i < maxLen; i++) { for (var i = 0; i < maxLen; i++) {
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0; final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0; final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
if (n1 < n2) return -1; if (n1 < n2) return -1;
if (n1 > n2) return 1; if (n1 > n2) return 1;
} }
@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) {
} }
class StoreCategory { class StoreCategory {
static const String metadata = 'metadata'; static const String metadata = 'metadata';
static const String download = 'download'; static const String download = 'download';
static const String utility = 'utility'; static const String utility = 'utility';
static const String lyrics = 'lyrics'; static const String lyrics = 'lyrics';
static const String integration = 'integration'; static const String integration = 'integration';
static const List<String> all = [metadata, download, utility, lyrics, integration]; static const List<String> all = [
metadata,
download,
utility,
lyrics,
integration,
];
static String getDisplayName(String category) { static String getDisplayName(String category) {
switch (category) { switch (category) {
@ -94,7 +99,8 @@ class StoreExtension {
return StoreExtension( return StoreExtension(
id: json['id'] as String? ?? '', id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '', name: json['name'] as String? ?? '',
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0', version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown', author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '', description: json['description'] as String? ?? '',
@ -117,7 +123,6 @@ class StoreExtension {
} }
} }
class StoreState { class StoreState {
final List<StoreExtension> extensions; final List<StoreExtension> extensions;
final String? selectedCategory; final String? selectedCategory;
@ -160,11 +165,15 @@ class StoreState {
}) { }) {
return StoreState( return StoreState(
extensions: extensions ?? this.extensions, extensions: extensions ?? this.extensions,
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory), selectedCategory: clearCategory
? null
: (selectedCategory ?? this.selectedCategory),
searchQuery: searchQuery ?? this.searchQuery, searchQuery: searchQuery ?? this.searchQuery,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
isDownloading: isDownloading ?? this.isDownloading, isDownloading: isDownloading ?? this.isDownloading,
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId), downloadingId: clearDownloadingId
? null
: (downloadingId ?? this.downloadingId),
error: clearError ? null : (error ?? this.error), error: clearError ? null : (error ?? this.error),
isInitialized: isInitialized ?? this.isInitialized, isInitialized: isInitialized ?? this.isInitialized,
registryUrl: registryUrl ?? this.registryUrl, registryUrl: registryUrl ?? this.registryUrl,
@ -180,13 +189,16 @@ class StoreState {
if (searchQuery.isNotEmpty) { if (searchQuery.isNotEmpty) {
final query = searchQuery.toLowerCase(); final query = searchQuery.toLowerCase();
result = result.where((e) => result = result
e.name.toLowerCase().contains(query) || .where(
e.displayName.toLowerCase().contains(query) || (e) =>
e.description.toLowerCase().contains(query) || e.name.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) || e.displayName.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)) e.description.toLowerCase().contains(query) ||
).toList(); e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)),
)
.toList();
} }
return result; return result;
@ -206,23 +218,28 @@ class StoreNotifier extends Notifier<StoreState> {
Future<void> initialize(String cacheDir) async { Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return; if (state.isInitialized) return;
state = state.copyWith(isLoading: true, clearError: true); // Load saved registry URL early to avoid UI flash (empty setup screen)
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
state = state.copyWith(
isLoading: true,
clearError: true,
registryUrl: savedUrl,
);
try { try {
await PlatformBridge.initExtensionStore(cacheDir); await PlatformBridge.initExtensionStore(cacheDir);
// Load saved registry URL from SharedPreferences
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
if (savedUrl.isNotEmpty) { if (savedUrl.isNotEmpty) {
await PlatformBridge.setStoreRegistryUrl(savedUrl); await PlatformBridge.setStoreRegistryUrl(savedUrl);
state = state.copyWith(registryUrl: savedUrl);
await refresh(); await refresh();
} }
state = state.copyWith(isInitialized: true, isLoading: false); state = state.copyWith(isInitialized: true, isLoading: false);
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})'); _log.i(
'Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})',
);
} catch (e) { } catch (e) {
_log.e('Failed to initialize store: $e'); _log.e('Failed to initialize store: $e');
state = state.copyWith(isLoading: false, error: e.toString()); state = state.copyWith(isLoading: false, error: e.toString());
@ -292,7 +309,9 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(isLoading: true, clearError: true); state = state.copyWith(isLoading: true, clearError: true);
try { try {
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh); final extensions = await PlatformBridge.getStoreExtensions(
forceRefresh: forceRefresh,
);
state = state.copyWith( state = state.copyWith(
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(), extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
isLoading: false, isLoading: false,
@ -320,12 +339,23 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: '', clearCategory: true); state = state.copyWith(searchQuery: '', clearCategory: true);
} }
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async { Future<bool> installExtension(
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); String extensionId,
String tempDir,
String extensionsDir,
) async {
state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
clearError: true,
);
try { try {
_log.i('Downloading extension: $extensionId'); _log.i('Downloading extension: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); final downloadPath = await PlatformBridge.downloadStoreExtension(
extensionId,
tempDir,
);
_log.i('Installing extension from: $downloadPath'); _log.i('Installing extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier); final extNotifier = ref.read(extensionProvider.notifier);
@ -340,18 +370,28 @@ class StoreNotifier extends Notifier<StoreState> {
return success; return success;
} catch (e) { } catch (e) {
_log.e('Failed to install extension: $e'); _log.e('Failed to install extension: $e');
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); state = state.copyWith(
isDownloading: false,
clearDownloadingId: true,
error: e.toString(),
);
return false; return false;
} }
} }
Future<bool> updateExtension(String extensionId, String tempDir) async { Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
clearError: true,
);
try { try {
_log.i('Downloading update for: $extensionId'); _log.i('Downloading update for: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); final downloadPath = await PlatformBridge.downloadStoreExtension(
extensionId,
tempDir,
);
_log.i('Upgrading extension from: $downloadPath'); _log.i('Upgrading extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier); final extNotifier = ref.read(extensionProvider.notifier);
@ -366,7 +406,11 @@ class StoreNotifier extends Notifier<StoreState> {
return success; return success;
} catch (e) { } catch (e) {
_log.e('Failed to update extension: $e'); _log.e('Failed to update extension: $e');
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); state = state.copyWith(
isDownloading: false,
clearDownloadingId: true,
error: e.toString(),
);
return false; return false;
} }
} }