feat: polish search empty state and share caching

This commit is contained in:
zarzet
2026-06-01 14:07:47 +07:00
parent b82dabe316
commit 421d5ffdc8
23 changed files with 443 additions and 172 deletions
+4
View File
@@ -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
+94 -1
View File
@@ -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(
+42
View File
@@ -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")
}
}
+8 -8
View File
@@ -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
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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`).
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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`).
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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';
}
+5 -5
View File
@@ -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`).
+6 -6
View File
@@ -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"
}
}
+6 -6
View File
@@ -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"
}
}
+116 -10
View File
@@ -46,6 +46,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
final _urlController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery;
String? _activeSearchInput;
bool _isResettingSearchSurface = false;
late final ProviderSubscription<TrackState> _trackStateSub;
late final ProviderSubscription<bool> _extensionInitSub;
late final ProviderSubscription<bool> _homeFeedExtSub;
@@ -557,9 +559,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
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<HomeTab>
}
Future<void> _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<void> _fetchMetadata() async {
@@ -1114,6 +1146,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
);
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<HomeTab>
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<HomeTab>
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<HomeTab>
final showExplore =
!hasActualResults &&
!isLoading &&
!hasActiveSearchSurface &&
!showRecentAccess &&
!homeFeedDisabled &&
(hasHomeFeedExtension || hasExploreContent) &&
@@ -1299,7 +1349,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
if (hasActualResults && !showRecentAccess)
if (hasActiveSearchSurface &&
!showRecentAccess &&
!showEmptySearchResult)
Consumer(
builder: (context, ref, _) {
final currentSearchProvider = ref.watch(
@@ -1466,7 +1518,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
(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<HomeTab>
error: error,
colorScheme: colorScheme,
hasResults: hasResults,
showEmptySearchResult: showEmptySearchResult,
searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@@ -2611,6 +2665,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
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<HomeTab>
required String? error,
required ColorScheme colorScheme,
required bool hasResults,
required bool showEmptySearchResult,
required String? searchExtensionId,
required bool showLocalLibraryIndicator,
required Map<String, (double, double)> thumbnailSizesByExtensionId,
@@ -2934,6 +3030,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
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;
+89 -63
View File
@@ -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<CrossExtensionShareSheet> createState() =>
ConsumerState<CrossExtensionShareSheet> createState() =>
_CrossExtensionShareSheetState();
}
class _CrossExtensionShareSheetState extends State<CrossExtensionShareSheet> {
class _CrossExtensionShareSheetState
extends ConsumerState<CrossExtensionShareSheet> {
late final Future<List<CrossExtensionShareResult>> _future;
@override
@@ -71,6 +76,18 @@ class _CrossExtensionShareSheetState extends State<CrossExtensionShareSheet> {
});
}
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<CrossExtensionShareSheet> {
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<CrossExtensionShareSheet> {
);
}
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<CrossExtensionShareSheet> {
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,
);
}
}
+8 -8
View File
@@ -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: