mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
feat(metadata): choose extension for online autofill
This commit is contained in:
@@ -1853,6 +1853,26 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchTracksWithMetadataProvider" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 20
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
val method = Gobackend::class.java.getMethod(
|
||||
"searchTracksWithMetadataProviderJSON",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
java.lang.Long.TYPE
|
||||
)
|
||||
method.invoke(
|
||||
null,
|
||||
extensionId,
|
||||
query,
|
||||
limit.toLong()
|
||||
) as? String ?: "[]"
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"findCollectionAcrossExtensions" -> {
|
||||
val requestJson = call.arguments as? String ?: "{}"
|
||||
val response: String = withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -442,6 +442,16 @@ func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExten
|
||||
return marshalJSONString(tracks)
|
||||
}
|
||||
|
||||
func SearchTracksWithMetadataProviderJSON(providerID, query string, limit int) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProvider(providerID, query, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalJSONString(tracks)
|
||||
}
|
||||
|
||||
func preflightExtensionDownloadSession(extensionID string) (bool, error) {
|
||||
extensionID = strings.TrimSpace(extensionID)
|
||||
if extensionID == "" {
|
||||
|
||||
@@ -104,6 +104,40 @@ func TestExtensionTrackExportsPreserveExplicitFlag(t *testing.T) {
|
||||
assertExplicit("URL handler", jsonText, err)
|
||||
}
|
||||
|
||||
func TestSearchTracksWithMetadataProviderUsesOnlySelectedExtension(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
|
||||
selected := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
selected.ID = "selected-metadata"
|
||||
selected.Manifest.Name = selected.ID
|
||||
other := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
other.ID = "other-metadata"
|
||||
other.Manifest.Name = other.ID
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
manager.extensions = map[string]*loadedExtension{
|
||||
selected.ID: selected,
|
||||
other.ID: other,
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
defer CleanupExtensions()
|
||||
|
||||
jsonText, err := SearchTracksWithMetadataProviderJSON(selected.ID, "needle", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviderJSON: %v", err)
|
||||
}
|
||||
if !strings.Contains(jsonText, `"provider_id":"selected-metadata"`) {
|
||||
t.Fatalf("expected selected provider attribution, got %s", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"provider_id":"other-metadata"`) {
|
||||
t.Fatalf("unexpected fallback to another provider: %s", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
|
||||
@@ -51,6 +51,46 @@ func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit
|
||||
return m.SearchTracksWithMetadataProvidersForItemID(query, limit, includeExtensions, "")
|
||||
}
|
||||
|
||||
// SearchTracksWithMetadataProvider searches one explicitly selected metadata
|
||||
// provider. Unlike the priority-based search, this never falls through to a
|
||||
// different extension, so callers can reliably attribute the returned fields
|
||||
// to the provider selected by the user.
|
||||
func (m *extensionManager) SearchTracksWithMetadataProvider(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" {
|
||||
return nil, fmt.Errorf("metadata provider ID is required")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
ext, err := m.GetExtension(providerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ext == nil || ext.Manifest == nil || !ext.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", providerID)
|
||||
}
|
||||
if !ext.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", providerID)
|
||||
}
|
||||
if ext.Error != "" {
|
||||
return nil, fmt.Errorf("extension '%s' is unavailable: %s", providerID, ext.Error)
|
||||
}
|
||||
|
||||
result, err := newExtensionProviderWrapper(ext).SearchTracks(query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil || len(result.Tracks) <= limit {
|
||||
if result == nil {
|
||||
return []ExtTrackMetadata{}, nil
|
||||
}
|
||||
return result.Tracks, nil
|
||||
}
|
||||
return result.Tracks[:limit], nil
|
||||
}
|
||||
|
||||
func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query string, limit int, includeExtensions bool, itemID string) ([]ExtTrackMetadata, error) {
|
||||
priority := GetMetadataProviderPriority()
|
||||
if limit <= 0 {
|
||||
|
||||
@@ -808,6 +808,20 @@ import Gobackend
|
||||
)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTracksWithMetadataProvider":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as? String ?? ""
|
||||
let query = args["query"] as? String ?? ""
|
||||
let limit = args["limit"] as? Int ?? 20
|
||||
let response = GobackendSearchTracksWithMetadataProviderJSON(
|
||||
extensionId,
|
||||
query,
|
||||
Int(limit),
|
||||
&error
|
||||
)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "enrichTrackWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
|
||||
@@ -5555,9 +5555,51 @@ abstract class AppLocalizations {
|
||||
/// Description for the auto-fill section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select fields to fill automatically from online metadata'**
|
||||
/// **'Choose a metadata extension, select fields, then review its data before applying'**
|
||||
String get editMetadataAutoFillDesc;
|
||||
|
||||
/// Label for the metadata extension selector in online auto-fill
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Metadata source'**
|
||||
String get editMetadataAutoFillSource;
|
||||
|
||||
/// Automatic source option that follows configured metadata provider priority
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Automatic (provider priority)'**
|
||||
String get editMetadataAutoFillSourceAutomatic;
|
||||
|
||||
/// Button label for finding an online metadata candidate
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Find metadata'**
|
||||
String get editMetadataAutoFillFind;
|
||||
|
||||
/// Title for the online metadata preview
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Data from {source}'**
|
||||
String editMetadataAutoFillPreview(String source);
|
||||
|
||||
/// Preview value when the selected metadata source provides cover artwork
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cover artwork available'**
|
||||
String get editMetadataAutoFillCoverAvailable;
|
||||
|
||||
/// Button label for applying the previewed metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply selected data'**
|
||||
String get editMetadataAutoFillApply;
|
||||
|
||||
/// Snackbar confirming fields applied from a selected metadata source
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filled {count} {count, plural, =1{field} other{fields}} from {source}'**
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source);
|
||||
|
||||
/// Button label to fetch online metadata and fill selected fields
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -3355,6 +3355,38 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Wähle Felder aus, die automatisch aus Online-Metadaten ausgefüllt werden sollen';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Abrufen & Ausfüllen';
|
||||
|
||||
|
||||
@@ -3313,7 +3313,39 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
'Choose a metadata extension, select fields, then review its data before applying';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@@ -3315,6 +3315,38 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
|
||||
@@ -3404,6 +3404,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Sélectionnez les champs à remplir automatiquement à partir des métadonnées en ligne';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Récupérer & remplir';
|
||||
|
||||
|
||||
@@ -3316,7 +3316,33 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
'Pilih extension metadata dan field, lalu periksa datanya sebelum diterapkan';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Sumber metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Otomatis (prioritas provider)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Cari metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data dari $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Sampul tersedia';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Terapkan data terpilih';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
return 'Mengisi $count field dari $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@@ -3304,6 +3304,38 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
|
||||
@@ -3218,6 +3218,38 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get editMetadataAutoFillDesc => '온라인 메타데이터에서 자동으로 채워질 필드를 선택하세요';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => '가져오기 & 채우기';
|
||||
|
||||
|
||||
@@ -3315,6 +3315,38 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
|
||||
@@ -3344,6 +3344,38 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Получить и заполнить';
|
||||
|
||||
|
||||
@@ -3345,6 +3345,38 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
|
||||
@@ -3358,6 +3358,38 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Виберіть поля для автоматичного заповнення з онлайн-метаданих';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSource => 'Metadata source';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSourceAutomatic =>
|
||||
'Automatic (provider priority)';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFind => 'Find metadata';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillPreview(String source) {
|
||||
return 'Data from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillCoverAvailable => 'Cover artwork available';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillApply => 'Apply selected data';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDoneFromSource(int count, String source) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Отримання та заповнення';
|
||||
|
||||
|
||||
+42
-1
@@ -4283,10 +4283,51 @@
|
||||
"@editMetadataAutoFill": {
|
||||
"description": "Section title for selective online metadata auto-fill in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata",
|
||||
"editMetadataAutoFillDesc": "Choose a metadata extension, select fields, then review its data before applying",
|
||||
"@editMetadataAutoFillDesc": {
|
||||
"description": "Description for the auto-fill section"
|
||||
},
|
||||
"editMetadataAutoFillSource": "Metadata source",
|
||||
"@editMetadataAutoFillSource": {
|
||||
"description": "Label for the metadata extension selector in online auto-fill"
|
||||
},
|
||||
"editMetadataAutoFillSourceAutomatic": "Automatic (provider priority)",
|
||||
"@editMetadataAutoFillSourceAutomatic": {
|
||||
"description": "Automatic source option that follows configured metadata provider priority"
|
||||
},
|
||||
"editMetadataAutoFillFind": "Find metadata",
|
||||
"@editMetadataAutoFillFind": {
|
||||
"description": "Button label for finding an online metadata candidate"
|
||||
},
|
||||
"editMetadataAutoFillPreview": "Data from {source}",
|
||||
"@editMetadataAutoFillPreview": {
|
||||
"description": "Title for the online metadata preview",
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFillCoverAvailable": "Cover artwork available",
|
||||
"@editMetadataAutoFillCoverAvailable": {
|
||||
"description": "Preview value when the selected metadata source provides cover artwork"
|
||||
},
|
||||
"editMetadataAutoFillApply": "Apply selected data",
|
||||
"@editMetadataAutoFillApply": {
|
||||
"description": "Button label for applying the previewed metadata"
|
||||
},
|
||||
"editMetadataAutoFillDoneFromSource": "Filled {count} {count, plural, =1{field} other{fields}} from {source}",
|
||||
"@editMetadataAutoFillDoneFromSource": {
|
||||
"description": "Snackbar confirming fields applied from a selected metadata source",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFillFetch": "Fetch & Fill",
|
||||
"@editMetadataAutoFillFetch": {
|
||||
"description": "Button label to fetch online metadata and fill selected fields"
|
||||
|
||||
+42
-1
@@ -2385,10 +2385,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata",
|
||||
"editMetadataAutoFillDesc": "Pilih extension metadata dan field, lalu periksa datanya sebelum diterapkan",
|
||||
"@editMetadataAutoFillDesc": {
|
||||
"description": "Description for the auto-fill section"
|
||||
},
|
||||
"editMetadataAutoFillSource": "Sumber metadata",
|
||||
"@editMetadataAutoFillSource": {
|
||||
"description": "Label for the metadata extension selector in online auto-fill"
|
||||
},
|
||||
"editMetadataAutoFillSourceAutomatic": "Otomatis (prioritas provider)",
|
||||
"@editMetadataAutoFillSourceAutomatic": {
|
||||
"description": "Automatic source option that follows configured metadata provider priority"
|
||||
},
|
||||
"editMetadataAutoFillFind": "Cari metadata",
|
||||
"@editMetadataAutoFillFind": {
|
||||
"description": "Button label for finding an online metadata candidate"
|
||||
},
|
||||
"editMetadataAutoFillPreview": "Data dari {source}",
|
||||
"@editMetadataAutoFillPreview": {
|
||||
"description": "Title for the online metadata preview",
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFillCoverAvailable": "Sampul tersedia",
|
||||
"@editMetadataAutoFillCoverAvailable": {
|
||||
"description": "Preview value when the selected metadata source provides cover artwork"
|
||||
},
|
||||
"editMetadataAutoFillApply": "Terapkan data terpilih",
|
||||
"@editMetadataAutoFillApply": {
|
||||
"description": "Button label for applying the previewed metadata"
|
||||
},
|
||||
"editMetadataAutoFillDoneFromSource": "Mengisi {count} field dari {source}",
|
||||
"@editMetadataAutoFillDoneFromSource": {
|
||||
"description": "Snackbar confirming fields applied from a selected metadata source",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@lyricsProviderExtensionDesc": {
|
||||
"description": "Generic description for extension-based lyrics providers"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,18 @@ class _ResolvedAutoFillTrack {
|
||||
const _ResolvedAutoFillTrack({required this.track, this.deezerId});
|
||||
}
|
||||
|
||||
class _AutoFillPreview {
|
||||
final Map<String, String> values;
|
||||
final String sourceName;
|
||||
final String? coverUrl;
|
||||
|
||||
const _AutoFillPreview({
|
||||
required this.values,
|
||||
required this.sourceName,
|
||||
this.coverUrl,
|
||||
});
|
||||
}
|
||||
|
||||
class _EditMetadataSheet extends StatefulWidget {
|
||||
static const _onlineCoverSentinel = '__online_cover__';
|
||||
final ColorScheme colorScheme;
|
||||
@@ -46,9 +58,50 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
String? _currentCoverPath;
|
||||
String? _currentCoverTempDir;
|
||||
bool _loadingCurrentCover = false;
|
||||
String? _selectedMetadataProviderId;
|
||||
_AutoFillPreview? _autoFillPreview;
|
||||
|
||||
final Set<String> _autoFillFields = {};
|
||||
|
||||
List<Extension> _orderedMetadataProviders(ExtensionState state) {
|
||||
final providersById = <String, Extension>{
|
||||
for (final extension in state.extensions)
|
||||
if (extension.enabled && extension.hasMetadataProvider)
|
||||
extension.id: extension,
|
||||
};
|
||||
final ordered = <Extension>[];
|
||||
for (final id in state.metadataProviderPriority) {
|
||||
final extension = providersById.remove(id);
|
||||
if (extension != null) ordered.add(extension);
|
||||
}
|
||||
final remaining = providersById.values.toList()
|
||||
..sort(
|
||||
(a, b) =>
|
||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
);
|
||||
return [...ordered, ...remaining];
|
||||
}
|
||||
|
||||
String _metadataProviderName(String? providerId) {
|
||||
final normalized = providerId?.trim() ?? '';
|
||||
if (normalized.isEmpty) {
|
||||
return context.l10n.editMetadataAutoFillSourceAutomatic;
|
||||
}
|
||||
final extensionState = ProviderScope.containerOf(
|
||||
context,
|
||||
listen: false,
|
||||
).read(extensionProvider);
|
||||
return extensionState.extensions
|
||||
.where((extension) => extension.id == normalized)
|
||||
.firstOrNull
|
||||
?.displayName ??
|
||||
normalized;
|
||||
}
|
||||
|
||||
void _invalidateAutoFillPreview() {
|
||||
_autoFillPreview = null;
|
||||
}
|
||||
|
||||
static const _fieldDefs = <String, String>{
|
||||
'title': 'title',
|
||||
'artist': 'artist',
|
||||
@@ -330,12 +383,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
|
||||
void _selectAllFields() {
|
||||
setState(() {
|
||||
_invalidateAutoFillPreview();
|
||||
_autoFillFields.addAll(_fieldDefs.keys);
|
||||
});
|
||||
}
|
||||
|
||||
void _selectEmptyFields() {
|
||||
setState(() {
|
||||
_invalidateAutoFillPreview();
|
||||
_autoFillFields.clear();
|
||||
for (final key in _fieldDefs.keys) {
|
||||
if (key == 'cover') {
|
||||
@@ -353,7 +408,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
}
|
||||
|
||||
void _selectNoFields() {
|
||||
setState(_autoFillFields.clear);
|
||||
setState(() {
|
||||
_invalidateAutoFillPreview();
|
||||
_autoFillFields.clear();
|
||||
});
|
||||
}
|
||||
|
||||
String _normalizeMetadataText(String value) {
|
||||
@@ -656,7 +714,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _fetchAndFill() async {
|
||||
Future<void> _fetchAutoFillPreview() async {
|
||||
if (_autoFillFields.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoneSelected)),
|
||||
@@ -664,7 +722,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _fetching = true);
|
||||
setState(() {
|
||||
_fetching = true;
|
||||
_invalidateAutoFillPreview();
|
||||
});
|
||||
final settingsNotifier = ProviderScope.containerOf(
|
||||
context,
|
||||
listen: false,
|
||||
@@ -675,12 +736,30 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
final artist = _artistCtrl.text.trim();
|
||||
final album = _albumCtrl.text.trim();
|
||||
final currentIsrc = _isrcCtrl.text.trim().toUpperCase();
|
||||
final configuredProviderId = _selectedMetadataProviderId?.trim();
|
||||
final extensionState = ProviderScope.containerOf(
|
||||
context,
|
||||
listen: false,
|
||||
).read(extensionProvider);
|
||||
final selectedProviderId =
|
||||
configuredProviderId != null &&
|
||||
extensionState.extensions.any(
|
||||
(extension) =>
|
||||
extension.id == configuredProviderId &&
|
||||
extension.enabled &&
|
||||
extension.hasMetadataProvider,
|
||||
)
|
||||
? configuredProviderId
|
||||
: null;
|
||||
final usesAutomaticProvider =
|
||||
selectedProviderId == null || selectedProviderId.isEmpty;
|
||||
final shouldFetchLyrics = _autoFillFields.contains('lyrics');
|
||||
final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics');
|
||||
Map<String, dynamic>? best;
|
||||
String? deezerId;
|
||||
String? lyricsSourceName;
|
||||
|
||||
if (needsTrackLookup) {
|
||||
if (needsTrackLookup && usesAutomaticProvider) {
|
||||
try {
|
||||
final resolved = await _resolveAutoFillTrackFromIdentifiers(
|
||||
currentIsrc,
|
||||
@@ -714,10 +793,16 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
|
||||
if (needsTrackLookup && best == null) {
|
||||
final query = queryParts.join(' ');
|
||||
final results = await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 5,
|
||||
);
|
||||
final results = usesAutomaticProvider
|
||||
? await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 5,
|
||||
)
|
||||
: await PlatformBridge.searchTracksWithMetadataProvider(
|
||||
selectedProviderId,
|
||||
query,
|
||||
limit: 5,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -762,6 +847,32 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usesAutomaticProvider) {
|
||||
final trackId = best['id']?.toString().trim() ?? '';
|
||||
if (trackId.isNotEmpty) {
|
||||
try {
|
||||
final details = await PlatformBridge.getProviderMetadata(
|
||||
selectedProviderId,
|
||||
'track',
|
||||
trackId,
|
||||
);
|
||||
final mergedDetails = <String, dynamic>{...best};
|
||||
for (final entry in _unwrapTrackPayload(details).entries) {
|
||||
final value = entry.value;
|
||||
if (value != null && value.toString().trim().isNotEmpty) {
|
||||
mergedDetails[entry.key] = value;
|
||||
}
|
||||
}
|
||||
best = mergedDetails;
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'Detailed metadata lookup failed for '
|
||||
'$selectedProviderId/$trackId: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final selectedBest = best;
|
||||
@@ -810,7 +921,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
? currentIsrc
|
||||
: (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : '');
|
||||
|
||||
if (needsIsrc || needsExtended) {
|
||||
if (usesAutomaticProvider && (needsIsrc || needsExtended)) {
|
||||
try {
|
||||
if (deezerId == null && deezerLookupIsrc.isNotEmpty) {
|
||||
final deezerResult = await PlatformBridge.searchDeezerByISRC(
|
||||
@@ -840,7 +951,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (needsIsrc &&
|
||||
if (usesAutomaticProvider &&
|
||||
needsIsrc &&
|
||||
(enriched['isrc'] ?? '').trim().isEmpty &&
|
||||
deezerId != null) {
|
||||
try {
|
||||
@@ -860,7 +972,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (needsExtended && deezerId != null) {
|
||||
if (usesAutomaticProvider && needsExtended && deezerId != null) {
|
||||
try {
|
||||
final extended = await PlatformBridge.getDeezerExtendedMetadata(
|
||||
deezerId,
|
||||
@@ -898,6 +1010,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
durationMs: widget.durationMs,
|
||||
);
|
||||
final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? '';
|
||||
final lyricsSource =
|
||||
lyricsResult['source']?.toString().trim() ?? '';
|
||||
if (lyricsSource.isNotEmpty) lyricsSourceName = lyricsSource;
|
||||
final instrumental =
|
||||
(lyricsResult['instrumental'] as bool? ?? false) ||
|
||||
lyricsText == '[instrumental:true]';
|
||||
@@ -912,72 +1027,116 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final availableValues = <String, String>{};
|
||||
for (final entry in enriched.entries) {
|
||||
final value = entry.value.trim();
|
||||
if (value.isNotEmpty && value != '0' && value != 'null') {
|
||||
availableValues[entry.key] = value;
|
||||
}
|
||||
}
|
||||
final coverUrl = selectedBest == null
|
||||
? null
|
||||
: (selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final hasSelectedValue = _autoFillFields.any(
|
||||
(key) => key == 'cover'
|
||||
? coverUrl != null && coverUrl.isNotEmpty
|
||||
: availableValues.containsKey(key),
|
||||
);
|
||||
if (!hasSelectedValue) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final resolvedSourceId = selectedProviderId?.isNotEmpty == true
|
||||
? selectedProviderId!
|
||||
: (selectedBest?['provider_id']?.toString().trim() ?? '');
|
||||
final resolvedSourceName =
|
||||
selectedBest == null && lyricsSourceName?.isNotEmpty == true
|
||||
? lyricsSourceName!
|
||||
: _metadataProviderName(resolvedSourceId);
|
||||
setState(() {
|
||||
_autoFillPreview = _AutoFillPreview(
|
||||
values: availableValues,
|
||||
sourceName: resolvedSourceName,
|
||||
coverUrl: coverUrl?.isNotEmpty == true ? coverUrl : null,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _fetching = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyAutoFillPreview() async {
|
||||
final preview = _autoFillPreview;
|
||||
if (preview == null || _saving || _fetching) return;
|
||||
|
||||
setState(() => _fetching = true);
|
||||
try {
|
||||
var filledCount = 0;
|
||||
for (final key in _autoFillFields) {
|
||||
if (key == 'cover') continue;
|
||||
final value = enriched[key];
|
||||
if (value != null &&
|
||||
value.isNotEmpty &&
|
||||
value != '0' &&
|
||||
value != 'null') {
|
||||
final ctrl = _controllerForKey(key);
|
||||
if (ctrl != null) {
|
||||
ctrl.text = value;
|
||||
filledCount++;
|
||||
}
|
||||
final value = preview.values[key];
|
||||
final ctrl = _controllerForKey(key);
|
||||
if (value != null && ctrl != null) {
|
||||
ctrl.text = value;
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (_autoFillFields.contains('cover') && selectedBest != null) {
|
||||
final coverUrl =
|
||||
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
|
||||
.toString();
|
||||
if (coverUrl.isNotEmpty) {
|
||||
try {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'autofill_cover_',
|
||||
);
|
||||
final coverOutput =
|
||||
'${tempDir.path}${Platform.pathSeparator}cover.jpg';
|
||||
await PlatformBridge.downloadCoverToFile(
|
||||
coverUrl,
|
||||
coverOutput,
|
||||
maxQuality: false,
|
||||
);
|
||||
final file = File(coverOutput);
|
||||
if (await file.exists() && await file.length() > 0) {
|
||||
await _cleanupSelectedCoverTemp();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedCoverPath = coverOutput;
|
||||
_selectedCoverTempDir = tempDir.path;
|
||||
_selectedCoverName = _EditMetadataSheet._onlineCoverSentinel;
|
||||
});
|
||||
filledCount++;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await tempDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {
|
||||
// Cover download is best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
filledCount > 0
|
||||
? context.l10n.editMetadataAutoFillDone(filledCount)
|
||||
: context.l10n.editMetadataAutoFillNoResults,
|
||||
),
|
||||
),
|
||||
if (_autoFillFields.contains('cover') && preview.coverUrl != null) {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'autofill_cover_',
|
||||
);
|
||||
final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
|
||||
try {
|
||||
await PlatformBridge.downloadCoverToFile(
|
||||
preview.coverUrl!,
|
||||
coverOutput,
|
||||
maxQuality: false,
|
||||
);
|
||||
final file = File(coverOutput);
|
||||
if (await file.exists() && await file.length() > 0) {
|
||||
await _cleanupSelectedCoverTemp();
|
||||
if (mounted) {
|
||||
_selectedCoverPath = coverOutput;
|
||||
_selectedCoverTempDir = tempDir.path;
|
||||
_selectedCoverName = _EditMetadataSheet._onlineCoverSentinel;
|
||||
filledCount++;
|
||||
}
|
||||
} else {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
} catch (_) {
|
||||
try {
|
||||
if (await tempDir.exists()) await tempDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
filledCount > 0
|
||||
? context.l10n.editMetadataAutoFillDoneFromSource(
|
||||
filledCount,
|
||||
preview.sourceName,
|
||||
)
|
||||
: context.l10n.editMetadataAutoFillNoResults,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1437,6 +1596,64 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
final providers = _orderedMetadataProviders(extensionState);
|
||||
final selectedId =
|
||||
providers.any(
|
||||
(extension) => extension.id == _selectedMetadataProviderId,
|
||||
)
|
||||
? _selectedMetadataProviderId!
|
||||
: '';
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.editMetadataAutoFillSource,
|
||||
prefixIcon: const Icon(Icons.extension_outlined),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedId,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: '',
|
||||
child: Text(
|
||||
context.l10n.editMetadataAutoFillSourceAutomatic,
|
||||
),
|
||||
),
|
||||
...providers.map(
|
||||
(extension) => DropdownMenuItem(
|
||||
value: extension.id,
|
||||
child: Text(
|
||||
extension.displayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (_fetching || _saving)
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_selectedMetadataProviderId =
|
||||
value == null || value.isEmpty ? null : value;
|
||||
_invalidateAutoFillPreview();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_quickSelectButton(
|
||||
@@ -1471,6 +1688,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
? null
|
||||
: (val) {
|
||||
setState(() {
|
||||
_invalidateAutoFillPreview();
|
||||
if (val) {
|
||||
_autoFillFields.add(key);
|
||||
} else {
|
||||
@@ -1494,7 +1712,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_fetching || _saving || _autoFillFields.isEmpty)
|
||||
? null
|
||||
: _fetchAndFill,
|
||||
: _fetchAutoFillPreview,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -1514,10 +1732,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
label: Text(
|
||||
_fetching
|
||||
? context.l10n.editMetadataAutoFillSearching
|
||||
: context.l10n.editMetadataAutoFillFetch,
|
||||
: context.l10n.editMetadataAutoFillFind,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_autoFillPreview case final preview?) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildAutoFillPreview(cs, preview),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -1547,6 +1769,82 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAutoFillPreview(ColorScheme cs, _AutoFillPreview preview) {
|
||||
final previewFields = _autoFillFields
|
||||
.where((key) {
|
||||
if (key == 'cover') return preview.coverUrl != null;
|
||||
return preview.values.containsKey(key);
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.fact_check_outlined, size: 18, color: cs.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.editMetadataAutoFillPreview(preview.sourceName),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...previewFields.map((key) {
|
||||
final value = key == 'cover'
|
||||
? context.l10n.editMetadataAutoFillCoverAvailable
|
||||
: preview.values[key]!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 104,
|
||||
child: Text(
|
||||
_fieldLabel(key),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
maxLines: key == 'lyrics' ? 3 : 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_fetching || _saving) ? null : _applyAutoFillPreview,
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text(context.l10n.editMetadataAutoFillApply),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fills genre and album artist from MusicBrainz (keyed by ISRC) as
|
||||
/// editable suggestions; nothing is saved until the user taps Save.
|
||||
Future<void> _fetchFromMusicBrainz() async {
|
||||
@@ -1585,9 +1883,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.editMetadataMusicBrainzNothing),
|
||||
),
|
||||
SnackBar(content: Text(context.l10n.editMetadataMusicBrainzNothing)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/replaygain_service.dart';
|
||||
|
||||
@@ -1365,6 +1365,19 @@ class PlatformBridge {
|
||||
return _decodeMapListResult(result, 'searchTracksWithMetadataProviders');
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> searchTracksWithMetadataProvider(
|
||||
String extensionId,
|
||||
String query, {
|
||||
int limit = 20,
|
||||
}) async {
|
||||
_log.d('searchTracksWithMetadataProvider: $extensionId, query="$query"');
|
||||
final result = await _channel.invokeMethod(
|
||||
'searchTracksWithMetadataProvider',
|
||||
{'extension_id': extensionId, 'query': query, 'limit': limit},
|
||||
);
|
||||
return _decodeMapListResult(result, 'searchTracksWithMetadataProvider');
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> findCollectionAcrossExtensions({
|
||||
required String name,
|
||||
required String artists,
|
||||
|
||||
@@ -75,4 +75,33 @@ void main() {
|
||||
expect(result['audio_codec'], 'opus');
|
||||
},
|
||||
);
|
||||
|
||||
test('metadata search sends the explicitly selected extension', () async {
|
||||
MethodCall? capturedCall;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(backendChannel, (call) async {
|
||||
capturedCall = call;
|
||||
return jsonEncode([
|
||||
{
|
||||
'id': 'track-1',
|
||||
'name': 'Song',
|
||||
'provider_id': 'selected-metadata',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
final results = await PlatformBridge.searchTracksWithMetadataProvider(
|
||||
'selected-metadata',
|
||||
'Song Artist',
|
||||
limit: 5,
|
||||
);
|
||||
|
||||
expect(capturedCall?.method, 'searchTracksWithMetadataProvider');
|
||||
expect(capturedCall?.arguments, {
|
||||
'extension_id': 'selected-metadata',
|
||||
'query': 'Song Artist',
|
||||
'limit': 5,
|
||||
});
|
||||
expect(results.single['provider_id'], 'selected-metadata');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user