mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
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:
parent
5e1cc3ecb5
commit
bf0f4bdf3e
2 changed files with 126 additions and 54 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue