From 421d5ffdc8a9b08c621148e0f4f61957af97630e Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 1 Jun 2026 14:07:47 +0700 Subject: [PATCH] feat: polish search empty state and share caching --- android/gradle.properties | 4 + go_backend/cross_extension_share.go | 95 +++++++++++- go_backend/cross_extension_share_test.go | 42 +++++ lib/l10n/app_localizations.dart | 16 +- lib/l10n/app_localizations_de.dart | 10 +- lib/l10n/app_localizations_en.dart | 10 +- lib/l10n/app_localizations_es.dart | 10 +- lib/l10n/app_localizations_fr.dart | 10 +- lib/l10n/app_localizations_hi.dart | 10 +- lib/l10n/app_localizations_id.dart | 10 +- lib/l10n/app_localizations_ja.dart | 10 +- lib/l10n/app_localizations_ko.dart | 10 +- lib/l10n/app_localizations_nl.dart | 10 +- lib/l10n/app_localizations_pt.dart | 10 +- lib/l10n/app_localizations_ru.dart | 10 +- lib/l10n/app_localizations_tr.dart | 10 +- lib/l10n/app_localizations_uk.dart | 10 +- lib/l10n/app_localizations_zh.dart | 10 +- lib/l10n/arb/app_en.arb | 12 +- lib/l10n/arb/app_id.arb | 12 +- lib/screens/home_tab.dart | 126 +++++++++++++-- lib/widgets/cross_extension_share_sheet.dart | 152 +++++++++++-------- pubspec.lock | 16 +- 23 files changed, 443 insertions(+), 172 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index adaa06f8..d7a85c66 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/go_backend/cross_extension_share.go b/go_backend/cross_extension_share.go index 640f94c1..7ae2c3f1 100644 --- a/go_backend/cross_extension_share.go +++ b/go_backend/cross_extension_share.go @@ -2,6 +2,7 @@ package gobackend import ( "encoding/json" + "sort" "strings" "sync" ) @@ -16,6 +17,16 @@ type CrossExtensionShareResult struct { Error string `json:"error,omitempty"` } +var crossExtensionShareResultCache = struct { + sync.RWMutex + entries map[string]string + order []string +}{ + entries: make(map[string]string), +} + +const crossExtensionShareResultCacheLimit = 128 + func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) { var req struct { Name string `json:"name"` @@ -49,6 +60,10 @@ func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) { } work = append(work, provider) } + cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work) + if cached := getCrossExtensionShareCache(cacheKey); cached != "" { + return cached, nil + } query := req.Name if req.Artists != "" { @@ -76,7 +91,85 @@ func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) { if err != nil { return "[]", err } - return string(data), nil + response := string(data) + if crossExtensionShareResultsCacheable(results) { + setCrossExtensionShareCache(cacheKey, response) + } + return response, nil +} + +func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string { + providerKeys := make([]string, 0, len(providers)) + for _, provider := range providers { + if provider == nil || provider.extension == nil { + continue + } + ext := provider.extension + displayName := "" + if ext.Manifest != nil { + displayName = ext.Manifest.DisplayName + } + providerKeys = append(providerKeys, strings.Join([]string{ + strings.TrimSpace(ext.ID), + strings.TrimSpace(displayName), + strings.TrimSpace(ext.SourceDir), + }, "\x1f")) + } + sort.Strings(providerKeys) + + return strings.Join([]string{ + normalizeLooseTitle(itemType), + normalizeLooseTitle(name), + normalizeLooseArtistName(artists), + strings.TrimSpace(sourceExtensionID), + strings.Join(providerKeys, "\x1e"), + }, "\x1d") +} + +func getCrossExtensionShareCache(key string) string { + if key == "" { + return "" + } + crossExtensionShareResultCache.RLock() + defer crossExtensionShareResultCache.RUnlock() + return crossExtensionShareResultCache.entries[key] +} + +func setCrossExtensionShareCache(key string, value string) { + if key == "" || value == "" { + return + } + crossExtensionShareResultCache.Lock() + defer crossExtensionShareResultCache.Unlock() + + if _, exists := crossExtensionShareResultCache.entries[key]; !exists { + crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key) + } + crossExtensionShareResultCache.entries[key] = value + + for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit { + oldest := crossExtensionShareResultCache.order[0] + crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:] + delete(crossExtensionShareResultCache.entries, oldest) + } +} + +func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool { + for _, result := range results { + if result.Found { + continue + } + errText := strings.ToLower(strings.TrimSpace(result.Error)) + if errText == "" || + errText == "no results" || + errText == "unsupported collection type" || + strings.HasSuffix(errText, " not found") || + strings.Contains(errText, "found without shareable link") { + continue + } + return false + } + return true } func findCollectionForExtension( diff --git a/go_backend/cross_extension_share_test.go b/go_backend/cross_extension_share_test.go index beb2a38d..76271d64 100644 --- a/go_backend/cross_extension_share_test.go +++ b/go_backend/cross_extension_share_test.go @@ -56,3 +56,45 @@ func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) { t.Fatalf("artist share URL = %q", url) } } + +func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) { + apple := &extensionProviderWrapper{ + extension: &loadedExtension{ + ID: "apple", + SourceDir: "/extensions/apple", + Manifest: &ExtensionManifest{DisplayName: "Apple Music"}, + }, + } + qobuz := &extensionProviderWrapper{ + extension: &loadedExtension{ + ID: "qobuz", + SourceDir: "/extensions/qobuz", + Manifest: &ExtensionManifest{DisplayName: "Qobuz"}, + }, + } + + first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz}) + second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple}) + if first != second { + t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second) + } +} + +func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) { + cacheable := []CrossExtensionShareResult{ + {ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"}, + {ExtensionID: "qobuz", Error: "album not found"}, + {ExtensionID: "tidal", Error: "no results"}, + } + if !crossExtensionShareResultsCacheable(cacheable) { + t.Fatal("expected found and deterministic not-found results to be cacheable") + } + + transient := []CrossExtensionShareResult{ + {ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"}, + {ExtensionID: "qobuz", Error: "request failed: timeout"}, + } + if crossExtensionShareResultsCacheable(transient) { + t.Fatal("expected transient extension errors to skip cache") + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5e2cd984..ac05e3b7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1404,6 +1404,12 @@ abstract class AppLocalizations { /// **'No tracks found'** String get errorNoTracksFound; + /// Subtitle shown under the empty search result state on the home screen + /// + /// In en, this message translates to: + /// **'Try another keyword'** + String get searchEmptyResultSubtitle; + /// Error title - URL not handled by any extension or service /// /// In en, this message translates to: @@ -7040,7 +7046,7 @@ abstract class AppLocalizations { /// Title and tooltip for finding the current collection in other services /// /// In en, this message translates to: - /// **'Open in other services'** + /// **'Open in Other Services'** String get openInOtherServices; /// Empty state when no extensions can be searched for cross-service links @@ -7058,7 +7064,7 @@ abstract class AppLocalizations { /// Tooltip for copying a cross-service link /// /// In en, this message translates to: - /// **'Copy link'** + /// **'Copy Link'** String get shareSheetCopyLink; /// Snackbar after copying a cross-service link @@ -7066,12 +7072,6 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{service} link copied'** String shareSheetLinkCopied(Object service); - - /// Tooltip for opening a cross-service link inside the app - /// - /// In en, this message translates to: - /// **'Open'** - String get shareSheetOpen; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6584222b..22236b5a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -754,6 +754,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get errorNoTracksFound => 'Keine Titel gefunden'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link wurde nicht erkannt'; @@ -4256,7 +4259,7 @@ class AppLocalizationsDe extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4265,13 +4268,10 @@ class AppLocalizationsDe extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1459432d..2573753c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -742,6 +742,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link not recognized'; @@ -4227,7 +4230,7 @@ class AppLocalizationsEn extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4236,13 +4239,10 @@ class AppLocalizationsEn extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 92120a26..ab61f22c 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -742,6 +742,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link not recognized'; @@ -4221,7 +4224,7 @@ class AppLocalizationsEs extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4230,15 +4233,12 @@ class AppLocalizationsEs extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 33cd3a5b..38001eb2 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -745,6 +745,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link not recognized'; @@ -4225,7 +4228,7 @@ class AppLocalizationsFr extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4234,13 +4237,10 @@ class AppLocalizationsFr extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index febf0b15..52243030 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -742,6 +742,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link not recognized'; @@ -4222,7 +4225,7 @@ class AppLocalizationsHi extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4231,13 +4234,10 @@ class AppLocalizationsHi extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index ecb41c71..21cedde3 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -745,6 +745,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get errorNoTracksFound => 'Tidak ada lagu ditemukan'; + @override + String get searchEmptyResultSubtitle => 'Coba kata kunci lain'; + @override String get errorUrlNotRecognized => 'Tautan tidak dikenali'; @@ -4213,7 +4216,7 @@ class AppLocalizationsId extends AppLocalizations { String get dialogDisableAndClear => 'Matikan dan hapus'; @override - String get openInOtherServices => 'Buka di layanan lain'; + String get openInOtherServices => 'Buka di Layanan Lain'; @override String get shareSheetNoExtensions => 'Tidak ada layanan lain yang kompatibel'; @@ -4222,13 +4225,10 @@ class AppLocalizationsId extends AppLocalizations { String get shareSheetNotFound => 'Tidak ditemukan'; @override - String get shareSheetCopyLink => 'Salin tautan'; + String get shareSheetCopyLink => 'Salin Tautan'; @override String shareSheetLinkCopied(Object service) { return 'Tautan $service disalin'; } - - @override - String get shareSheetOpen => 'Buka'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 5fdd7505..73ade9d5 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -737,6 +737,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get errorNoTracksFound => 'トラックがありません'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link not recognized'; @@ -4209,7 +4212,7 @@ class AppLocalizationsJa extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4218,13 +4221,10 @@ class AppLocalizationsJa extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index b5276eb2..acd639e6 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -724,6 +724,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get errorNoTracksFound => '트랙을 찾을 수 없습니다'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link not recognized'; @@ -4202,7 +4205,7 @@ class AppLocalizationsKo extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4211,13 +4214,10 @@ class AppLocalizationsKo extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 18ea1f2b..afcafbdf 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -742,6 +742,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link not recognized'; @@ -4222,7 +4225,7 @@ class AppLocalizationsNl extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4231,13 +4234,10 @@ class AppLocalizationsNl extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ea5048c1..998802d0 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -742,6 +742,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link not recognized'; @@ -4221,7 +4224,7 @@ class AppLocalizationsPt extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4230,15 +4233,12 @@ class AppLocalizationsPt extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d1461a7c..bbc648be 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -755,6 +755,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get errorNoTracksFound => 'Треки не найдены'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Ссылка не распознана'; @@ -4281,7 +4284,7 @@ class AppLocalizationsRu extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4290,13 +4293,10 @@ class AppLocalizationsRu extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 2f39d0ac..6d5e985c 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -750,6 +750,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get errorNoTracksFound => 'Parça bulunamadı'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Bağlantı tanınamadı'; @@ -4248,7 +4251,7 @@ class AppLocalizationsTr extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4257,13 +4260,10 @@ class AppLocalizationsTr extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index c3632d0a..d954d087 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -755,6 +755,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get errorNoTracksFound => 'Треків не знайдено'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Посилання не розпізнано'; @@ -4281,7 +4284,7 @@ class AppLocalizationsUk extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4290,13 +4293,10 @@ class AppLocalizationsUk extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f63f58af..0c1ca11d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -742,6 +742,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get errorNoTracksFound => 'No tracks found'; + @override + String get searchEmptyResultSubtitle => 'Try another keyword'; + @override String get errorUrlNotRecognized => 'Link not recognized'; @@ -4221,7 +4224,7 @@ class AppLocalizationsZh extends AppLocalizations { String get dialogDisableAndClear => 'Turn off and clear'; @override - String get openInOtherServices => 'Open in other services'; + String get openInOtherServices => 'Open in Other Services'; @override String get shareSheetNoExtensions => 'No other compatible services'; @@ -4230,15 +4233,12 @@ class AppLocalizationsZh extends AppLocalizations { String get shareSheetNotFound => 'Not found'; @override - String get shareSheetCopyLink => 'Copy link'; + String get shareSheetCopyLink => 'Copy Link'; @override String shareSheetLinkCopied(Object service) { return '$service link copied'; } - - @override - String get shareSheetOpen => 'Open'; } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 245a7d45..20ab89d7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -961,6 +961,10 @@ "@errorNoTracksFound": { "description": "Error - search returned no results" }, + "searchEmptyResultSubtitle": "Try another keyword", + "@searchEmptyResultSubtitle": { + "description": "Subtitle shown under the empty search result state on the home screen" + }, "errorUrlNotRecognized": "Link not recognized", "@errorUrlNotRecognized": { "description": "Error title - URL not handled by any extension or service" @@ -5528,7 +5532,7 @@ "@dialogDisableAndClear": { "description": "Confirmation action to disable download history and clear existing entries" }, - "openInOtherServices": "Open in other services", + "openInOtherServices": "Open in Other Services", "@openInOtherServices": { "description": "Title and tooltip for finding the current collection in other services" }, @@ -5540,7 +5544,7 @@ "@shareSheetNotFound": { "description": "Cross-service share sheet row subtitle when a service has no match" }, - "shareSheetCopyLink": "Copy link", + "shareSheetCopyLink": "Copy Link", "@shareSheetCopyLink": { "description": "Tooltip for copying a cross-service link" }, @@ -5550,9 +5554,5 @@ "placeholders": { "service": {} } - }, - "shareSheetOpen": "Open", - "@shareSheetOpen": { - "description": "Tooltip for opening a cross-service link inside the app" } } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index bd30b129..8ec985fa 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -905,6 +905,10 @@ "@errorNoTracksFound": { "description": "Error - search returned no results" }, + "searchEmptyResultSubtitle": "Coba kata kunci lain", + "@searchEmptyResultSubtitle": { + "description": "Subtitle shown under the empty search result state on the home screen" + }, "errorUrlNotRecognized": "Tautan tidak dikenali", "@errorUrlNotRecognized": { "description": "Error title - URL not handled by any extension or service" @@ -4656,7 +4660,7 @@ "@dialogDisableAndClear": { "description": "Confirmation action to disable download history and clear existing entries" }, - "openInOtherServices": "Buka di layanan lain", + "openInOtherServices": "Buka di Layanan Lain", "@openInOtherServices": { "description": "Title and tooltip for finding the current collection in other services" }, @@ -4668,7 +4672,7 @@ "@shareSheetNotFound": { "description": "Cross-service share sheet row subtitle when a service has no match" }, - "shareSheetCopyLink": "Salin tautan", + "shareSheetCopyLink": "Salin Tautan", "@shareSheetCopyLink": { "description": "Tooltip for copying a cross-service link" }, @@ -4678,9 +4682,5 @@ "placeholders": { "service": {} } - }, - "shareSheetOpen": "Buka", - "@shareSheetOpen": { - "description": "Tooltip for opening a cross-service link inside the app" } } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d0a0faf3..31bce767 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -46,6 +46,8 @@ class _HomeTabState extends ConsumerState final _urlController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); String? _lastSearchQuery; + String? _activeSearchInput; + bool _isResettingSearchSurface = false; late final ProviderSubscription _trackStateSub; late final ProviderSubscription _extensionInitSub; late final ProviderSubscription _homeFeedExtSub; @@ -557,9 +559,18 @@ class _HomeTabState extends ConsumerState if (text.isEmpty) { _liveSearchDebounce?.cancel(); + _activeSearchInput = null; + _lastSearchQuery = null; + if (!_isResettingSearchSurface) { + _resetSearchSurface(clearText: false); + } return; } + if (_activeSearchInput != null && _activeSearchInput != text) { + _activeSearchInput = null; + } + if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) { if (text.startsWith('http') || text.startsWith('spotify:')) return; @@ -638,10 +649,17 @@ class _HomeTabState extends ConsumerState final searchKey = '${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}'; - if (_lastSearchQuery == searchKey) return; + if (_lastSearchQuery == searchKey) { + _activeSearchInput = query; + ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty); + if (mounted) setState(() {}); + return; + } _lastSearchQuery = searchKey; + _activeSearchInput = query; _searchSortOption = _SearchSortOption.defaultOrder; _invalidateSearchSortCaches(); + ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty); final isExtensionEnabled = searchProvider != null && @@ -686,12 +704,26 @@ class _HomeTabState extends ConsumerState } Future _clearAndRefresh() async { - _liveSearchDebounce?.cancel(); - _pendingLiveSearchQuery = null; - _urlController.clear(); - _searchFocusNode.unfocus(); - _lastSearchQuery = null; - ref.read(trackProvider.notifier).clear(); + _resetSearchSurface(); + } + + void _resetSearchSurface({bool clearText = true}) { + if (_isResettingSearchSurface) return; + _isResettingSearchSurface = true; + try { + _liveSearchDebounce?.cancel(); + _pendingLiveSearchQuery = null; + _lastSearchQuery = null; + _activeSearchInput = null; + FocusManager.instance.primaryFocus?.unfocus(); + if (clearText && _urlController.text.isNotEmpty) { + _urlController.clear(); + } + ref.read(trackProvider.notifier).clear(); + if (mounted) setState(() {}); + } finally { + _isResettingSearchSurface = false; + } } Future _fetchMetadata() async { @@ -1114,6 +1146,7 @@ class _HomeTabState extends ConsumerState ), ); final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); + final searchError = ref.watch(trackProvider.select((s) => s.error)); final hasSearchedBefore = ref.watch( settingsProvider.select((s) => s.hasSearchedBefore), ); @@ -1153,6 +1186,18 @@ class _HomeTabState extends ConsumerState final isSearchFocused = _searchFocusNode.hasFocus; final hasShortSearchInput = hasSearchInput && searchText.length < _minLiveSearchChars; + final hasSearchError = hasSearchInput && searchError != null; + final hasActiveSearchSurface = + hasSearchInput && + (_activeSearchInput == searchText || + hasActualResults || + isLoading || + hasSearchError); + final showEmptySearchResult = + hasActiveSearchSurface && + !hasActualResults && + !isLoading && + searchError == null; final isShowingRecentAccess = ref.watch( trackProvider.select((s) => s.isShowingRecentAccess), ); @@ -1166,7 +1211,11 @@ class _HomeTabState extends ConsumerState final recentModeRequested = isShowingRecentAccess || isSearchFocused; final showRecentAccess = recentModeRequested && - (!hasSearchInput || hasShortSearchInput || !hasActualResults) && + (!hasSearchInput || + hasShortSearchInput || + (!hasActualResults && + !hasSearchError && + !hasActiveSearchSurface)) && !isLoading; final isSearchProviderLoading = !extensionReadiness.isInitialized && extensionReadiness.error == null; @@ -1180,6 +1229,7 @@ class _HomeTabState extends ConsumerState final showExplore = !hasActualResults && !isLoading && + !hasActiveSearchSurface && !showRecentAccess && !homeFeedDisabled && (hasHomeFeedExtension || hasExploreContent) && @@ -1299,7 +1349,9 @@ class _HomeTabState extends ConsumerState ), ), - if (hasActualResults && !showRecentAccess) + if (hasActiveSearchSurface && + !showRecentAccess && + !showEmptySearchResult) Consumer( builder: (context, ref, _) { final currentSearchProvider = ref.watch( @@ -1466,7 +1518,8 @@ class _HomeTabState extends ConsumerState (searchAlbums != null && searchAlbums.isNotEmpty) || (searchPlaylists != null && searchPlaylists.isNotEmpty) || isLoading || - error != null; + error != null || + hasActiveSearchSurface; return SliverMainAxisGroup( slivers: _buildSearchResults( @@ -1478,6 +1531,7 @@ class _HomeTabState extends ConsumerState error: error, colorScheme: colorScheme, hasResults: hasResults, + showEmptySearchResult: showEmptySearchResult, searchExtensionId: searchExtensionId, showLocalLibraryIndicator: showLocalLibraryIndicator, thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, @@ -2611,6 +2665,47 @@ class _HomeTabState extends ConsumerState ); } + Widget _buildEmptySearchResultWidget(ColorScheme colorScheme) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 340), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 86, + height: 86, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.surfaceContainerHighest, + ), + child: Icon( + Icons.manage_search, + size: 46, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.errorNoTracksFound, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + context.l10n.searchEmptyResultSubtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ); + } + String _sortOptionLabel(_SearchSortOption option) { switch (option) { case _SearchSortOption.defaultOrder: @@ -2888,6 +2983,7 @@ class _HomeTabState extends ConsumerState required String? error, required ColorScheme colorScheme, required bool hasResults, + required bool showEmptySearchResult, required String? searchExtensionId, required bool showLocalLibraryIndicator, required Map thumbnailSizesByExtensionId, @@ -2934,6 +3030,16 @@ class _HomeTabState extends ConsumerState child: LinearProgressIndicator(), ), ), + if (showEmptySearchResult && !hasActualData) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 96), + child: _buildEmptySearchResultWidget(colorScheme), + ), + ), + ), ]; bool sortButtonShown = false; diff --git a/lib/widgets/cross_extension_share_sheet.dart b/lib/widgets/cross_extension_share_sheet.dart index 689408cc..b36e215d 100644 --- a/lib/widgets/cross_extension_share_sheet.dart +++ b/lib/widgets/cross_extension_share_sheet.dart @@ -1,10 +1,14 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/cross_extension_share_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; -class CrossExtensionShareSheet extends StatefulWidget { +class CrossExtensionShareSheet extends ConsumerStatefulWidget { final String name; final String artists; final String type; @@ -44,11 +48,12 @@ class CrossExtensionShareSheet extends StatefulWidget { } @override - State createState() => + ConsumerState createState() => _CrossExtensionShareSheetState(); } -class _CrossExtensionShareSheetState extends State { +class _CrossExtensionShareSheetState + extends ConsumerState { late final Future> _future; @override @@ -71,6 +76,18 @@ class _CrossExtensionShareSheetState extends State { }); } + String? _iconPathFor(String extensionId) { + if (extensionId.isEmpty) return null; + final extensions = ref.read(extensionProvider).extensions; + for (final ext in extensions) { + if (ext.id == extensionId) { + final path = ext.iconPath; + return (path != null && path.isNotEmpty) ? path : null; + } + } + return null; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -86,25 +103,23 @@ class _CrossExtensionShareSheetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SizedBox(height: 8), Center( - child: Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), ), ), ), Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 4), child: Text( context.l10n.openInOtherServices, - style: textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), ), ), @@ -147,14 +162,16 @@ class _CrossExtensionShareSheetState extends State { ); } - return ListView.separated( + return ListView.builder( shrinkWrap: true, - padding: const EdgeInsets.fromLTRB(12, 4, 12, 16), + padding: const EdgeInsets.only(bottom: 16, top: 4), itemBuilder: (context, index) { - return _CrossExtensionShareTile(result: results[index]); + final result = results[index]; + return _CrossExtensionShareTile( + result: result, + iconPath: _iconPathFor(result.extensionId), + ); }, - separatorBuilder: (_, _) => - const Divider(height: 1, indent: 72), itemCount: results.length, ); }, @@ -169,33 +186,28 @@ class _CrossExtensionShareSheetState extends State { class _CrossExtensionShareTile extends StatelessWidget { final CrossExtensionShareResult result; + final String? iconPath; - const _CrossExtensionShareTile({required this.result}); + const _CrossExtensionShareTile({required this.result, this.iconPath}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final url = result.found ? result.url : null; - final hasUrl = url != null && url.isNotEmpty; + final url = result.url; + final hasUrl = result.found && url != null && url.isNotEmpty; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + final tile = ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), leading: Container( width: 44, height: 44, decoration: BoxDecoration( - color: hasUrl - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - hasUrl ? Icons.link_rounded : Icons.link_off_rounded, - color: hasUrl - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), ), + clipBehavior: Clip.antiAlias, + child: _buildIcon(colorScheme), ), title: Text( result.displayName, @@ -210,39 +222,53 @@ class _CrossExtensionShareTile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.bodySmall?.copyWith( - color: hasUrl ? colorScheme.primary : colorScheme.onSurfaceVariant, + color: colorScheme.onSurfaceVariant, ), ), trailing: hasUrl - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: context.l10n.shareSheetCopyLink, - icon: const Icon(Icons.copy_rounded, size: 20), - onPressed: () { - Clipboard.setData(ClipboardData(text: url)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.shareSheetLinkCopied(result.displayName), - ), - ), - ); - }, - ), - IconButton( - tooltip: context.l10n.shareSheetOpen, - icon: const Icon(Icons.open_in_new_rounded, size: 20), - color: colorScheme.primary, - onPressed: () { - Navigator.pop(context); - ShareIntentService().injectUrl(url); - }, - ), - ], + ? IconButton( + tooltip: context.l10n.shareSheetCopyLink, + icon: const Icon(Icons.copy_rounded, size: 20), + color: colorScheme.onSurfaceVariant, + onPressed: () { + Clipboard.setData(ClipboardData(text: url)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.shareSheetLinkCopied(result.displayName), + ), + ), + ); + }, ) : null, + onTap: hasUrl + ? () { + Navigator.pop(context); + ShareIntentService().injectUrl(url); + } + : null, + ); + + if (hasUrl) return tile; + return Opacity(opacity: 0.5, child: tile); + } + + Widget _buildIcon(ColorScheme colorScheme) { + final fallbackIcon = Icon( + Icons.extension_rounded, + color: colorScheme.onSurfaceVariant, + ); + + final path = iconPath; + if (path == null) return fallbackIcon; + + return Image.file( + File(path), + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallbackIcon, ); } } diff --git a/pubspec.lock b/pubspec.lock index 42565ec4..e435c1e6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -705,10 +705,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1246,26 +1246,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.17" timezone: dependency: transitive description: