From ffdaf14ba50152d59cfe8678135f4312cc597de4 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 31 May 2026 21:59:39 +0700 Subject: [PATCH] feat: rebuild cross-extension sharing and queue controls Co-authored-by: Amonoman --- .../com/zarz/spotiflac/DownloadService.kt | 3 +- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 11 + .../zarz/spotiflac/NativeDownloadFinalizer.kt | 14 +- go_backend/cross_extension_share.go | 286 ++++++++++++++++++ go_backend/exports.go | 5 + go_backend/extension_providers.go | 10 + lib/l10n/app_localizations.dart | 78 +++++ lib/l10n/app_localizations_de.dart | 45 +++ lib/l10n/app_localizations_en.dart | 45 +++ lib/l10n/app_localizations_es.dart | 45 +++ lib/l10n/app_localizations_fr.dart | 45 +++ lib/l10n/app_localizations_hi.dart | 45 +++ lib/l10n/app_localizations_id.dart | 45 +++ lib/l10n/app_localizations_ja.dart | 45 +++ lib/l10n/app_localizations_ko.dart | 45 +++ lib/l10n/app_localizations_nl.dart | 45 +++ lib/l10n/app_localizations_pt.dart | 45 +++ lib/l10n/app_localizations_ru.dart | 45 +++ lib/l10n/app_localizations_tr.dart | 45 +++ lib/l10n/app_localizations_uk.dart | 45 +++ lib/l10n/app_localizations_zh.dart | 45 +++ lib/l10n/arb/app_en.arb | 60 ++++ lib/l10n/arb/app_id.arb | 60 ++++ lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 262 ++++++++++------ lib/providers/settings_provider.dart | 5 + lib/screens/album_screen.dart | 42 +++ lib/screens/artist_screen.dart | 30 +- lib/screens/queue_tab.dart | 61 +++- lib/screens/settings/app_settings_page.dart | 61 ++++ lib/screens/track_metadata_edit_sheet.dart | 10 + .../cross_extension_share_service.dart | 52 ++++ lib/services/notification_service.dart | 8 + lib/services/platform_bridge.dart | 19 ++ lib/services/share_intent_service.dart | 6 + lib/widgets/app_announcement_dialog.dart | 5 +- lib/widgets/cross_extension_share_sheet.dart | 248 +++++++++++++++ 38 files changed, 1850 insertions(+), 122 deletions(-) create mode 100644 go_backend/cross_extension_share.go create mode 100644 lib/services/cross_extension_share_service.dart create mode 100644 lib/widgets/cross_extension_share_sheet.dart diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index 56ed75ad..e51e7a86 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -690,7 +690,8 @@ class DownloadService : Service() { request.itemId, request.requestJson, request.itemJson, - result + result, + settingsJson ) { nativeWorkerCancelRequested || nativeWorkerPaused || diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index b7f6eed0..e653cfb8 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -3157,6 +3157,17 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "findCollectionAcrossExtensions" -> { + val requestJson = call.arguments as? String ?: "{}" + val response: String = withContext(Dispatchers.IO) { + val method = Gobackend::class.java.getMethod( + "findCollectionAcrossExtensionsJSON", + String::class.java + ) + method.invoke(null, requestJson) as? String ?: "[]" + } + result.success(response) + } "enrichTrackWithExtension" -> { val extensionId = call.argument("extension_id") ?: "" val trackJson = call.argument("track") ?: "{}" diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 630ed6eb..122c7e0d 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -146,6 +146,7 @@ object NativeDownloadFinalizer { requestJson: String, itemJson: String, result: JSONObject, + settingsJson: String = "{}", shouldCancel: () -> Boolean = { false }, ): JSONObject { if (!result.optBoolean("success", false)) return result @@ -217,15 +218,20 @@ object NativeDownloadFinalizer { refreshFinalAudioQualityMetadata(context, result, state) } - val history = buildHistoryRow(effectiveInput, state) - upsertHistory(context, history) + val saveDownloadHistory = parseObject(settingsJson) + .optBoolean("save_download_history", true) + val history = if (saveDownloadHistory) { + buildHistoryRow(effectiveInput, state).also { upsertHistory(context, it) } + } else { + null + } result.put("file_path", state.filePath) if (state.fileName.isNotBlank()) result.put("file_name", state.fileName) if (state.quality.isNotBlank()) result.put("quality", state.quality) result.put("native_finalized", true) - result.put("history_written", true) - result.put("history_item", historyToJson(history)) + result.put("history_written", history != null) + if (history != null) result.put("history_item", historyToJson(history)) } catch (e: CancellationException) { cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath) result.put("success", false) diff --git a/go_backend/cross_extension_share.go b/go_backend/cross_extension_share.go new file mode 100644 index 00000000..4bde91b3 --- /dev/null +++ b/go_backend/cross_extension_share.go @@ -0,0 +1,286 @@ +package gobackend + +import ( + "encoding/json" + "strings" + "sync" +) + +type CrossExtensionShareResult struct { + ExtensionID string `json:"extension_id"` + DisplayName string `json:"display_name"` + Found bool `json:"found"` + URL string `json:"url,omitempty"` + ItemName string `json:"item_name,omitempty"` + ItemArtists string `json:"item_artists,omitempty"` + Error string `json:"error,omitempty"` +} + +func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) { + var req struct { + Name string `json:"name"` + Artists string `json:"artists"` + Type string `json:"type"` + SourceExtensionID string `json:"source_extension_id"` + } + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return "", err + } + + req.Name = strings.TrimSpace(req.Name) + req.Artists = strings.TrimSpace(req.Artists) + req.Type = strings.ToLower(strings.TrimSpace(req.Type)) + req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID) + if req.Name == "" { + return "[]", nil + } + if req.Type == "" { + req.Type = "album" + } + + providers := getExtensionManager().GetMetadataProviders() + work := make([]*extensionProviderWrapper, 0, len(providers)) + for _, provider := range providers { + if provider == nil || provider.extension == nil { + continue + } + if provider.extension.ID == req.SourceExtensionID { + continue + } + work = append(work, provider) + } + + query := req.Name + if req.Artists != "" { + query += " " + req.Artists + } + + results := make([]CrossExtensionShareResult, len(work)) + var wg sync.WaitGroup + for i, provider := range work { + wg.Add(1) + go func(index int, p *extensionProviderWrapper) { + defer wg.Done() + results[index] = findCollectionForExtension( + p, + req.Type, + req.Name, + req.Artists, + query, + ) + }(i, provider) + } + wg.Wait() + + data, err := json.Marshal(results) + if err != nil { + return "[]", err + } + return string(data), nil +} + +func findCollectionForExtension( + provider *extensionProviderWrapper, + itemType string, + name string, + artists string, + query string, +) CrossExtensionShareResult { + result := CrossExtensionShareResult{ + ExtensionID: provider.extension.ID, + } + if provider.extension.Manifest != nil { + result.DisplayName = provider.extension.Manifest.DisplayName + } + if result.DisplayName == "" { + result.DisplayName = provider.extension.ID + } + + searchResult, err := provider.SearchTracks(query, 10) + if err != nil { + result.Error = err.Error() + return result + } + if searchResult == nil || len(searchResult.Tracks) == 0 { + result.Error = "no results" + return result + } + + var best *ExtTrackMetadata + switch itemType { + case "artist": + best = bestArtistTrack(searchResult.Tracks, name) + case "album": + best = bestAlbumTrack(searchResult.Tracks, name, artists) + default: + result.Error = "unsupported collection type" + return result + } + if best == nil { + result.Error = itemType + " not found" + return result + } + + url := resolveCollectionShareURL(provider.extension, itemType, best) + if url == "" { + result.Error = itemType + " found without shareable link" + return result + } + + result.Found = true + result.URL = url + if itemType == "artist" { + result.ItemName = best.Artists + } else { + result.ItemName = best.AlbumName + result.ItemArtists = best.Artists + } + return result +} + +func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata { + targetAlbum := normalizeLooseTitle(albumName) + targetArtists := normalizeLooseArtistName(artists) + bestScore := 0 + bestIndex := -1 + + for i := range tracks { + track := tracks[i] + album := normalizeLooseTitle(track.AlbumName) + trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist) + + score := 0 + if album == targetAlbum { + score += 100 + } else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) { + score += 50 + } + if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) { + score += 30 + } + if score > bestScore { + bestScore = score + bestIndex = i + } + } + + if bestIndex < 0 || bestScore < 50 { + return nil + } + return &tracks[bestIndex] +} + +func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata { + targetArtist := normalizeLooseArtistName(artistName) + bestScore := 0 + bestIndex := -1 + + for i := range tracks { + artist := normalizeLooseArtistName(tracks[i].Artists) + score := 0 + if artist == targetArtist { + score += 100 + } else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) { + score += 60 + } + if score > bestScore { + bestScore = score + bestIndex = i + } + } + + if bestIndex < 0 || bestScore < 60 { + return nil + } + return &tracks[bestIndex] +} + +func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string { + if track == nil { + return "" + } + + if itemType == "album" { + if url := normalizeShareURL(track.AlbumURL); url != "" { + return url + } + if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" { + return url + } + if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, track.AlbumURL)); url != "" { + return url + } + return "" + } + + if url := normalizeShareURL(track.ArtistURL); url != "" { + return url + } + if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" { + return url + } + if url := templateShareURL(ext, "artist", track.ArtistID); url != "" { + return url + } + return "" +} + +func normalizeShareURL(value string) string { + trimmed := strings.TrimSpace(value) + if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") { + return trimmed + } + return "" +} + +func urlFromExternalLinks(links map[string]string, preferredKey string) string { + for key, value := range links { + if strings.Contains(strings.ToLower(key), preferredKey) { + if url := normalizeShareURL(value); url != "" { + return url + } + } + } + return "" +} + +func templateShareURL(ext *loadedExtension, itemType string, id string) string { + if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil { + return "" + } + id = stripProviderPrefix(strings.TrimSpace(id)) + if id == "" { + return "" + } + + templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{}) + if !ok { + return "" + } + rawTemplate, ok := templates[itemType].(string) + if !ok { + return "" + } + rawTemplate = strings.TrimSpace(rawTemplate) + if rawTemplate == "" { + return "" + } + return strings.ReplaceAll(rawTemplate, "{id}", id) +} + +func stripProviderPrefix(id string) string { + if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 { + return id[index+1:] + } + return id +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/go_backend/exports.go b/go_backend/exports.go index 9150ba67..6fcfe2c5 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1956,6 +1956,11 @@ func normalizeExtensionTrackMetadataMap( "artists": track.Artists, "album_name": track.AlbumName, "album_artist": track.AlbumArtist, + "album_id": track.AlbumID, + "album_url": track.AlbumURL, + "artist_id": track.ArtistID, + "artist_url": track.ArtistURL, + "external_urls": track.ExternalURL, "duration_ms": track.DurationMS, "images": coverURL, "cover_url": coverURL, diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 67935d92..de546808 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -22,6 +22,11 @@ type ExtTrackMetadata struct { Artists string `json:"artists"` AlbumName string `json:"album_name"` AlbumArtist string `json:"album_artist,omitempty"` + AlbumID string `json:"album_id,omitempty"` + AlbumURL string `json:"album_url,omitempty"` + ArtistID string `json:"artist_id,omitempty"` + ArtistURL string `json:"artist_url,omitempty"` + ExternalURL string `json:"external_urls,omitempty"` DurationMS int `json:"duration_ms"` CoverURL string `json:"cover_url,omitempty"` Images string `json:"images,omitempty"` @@ -684,6 +689,11 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada Artists: gojaObjectString(obj, "artists"), AlbumName: gojaObjectString(obj, "album_name", "albumName"), AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), + AlbumID: gojaObjectString(obj, "album_id", "albumId"), + AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"), + ArtistID: gojaObjectString(obj, "artist_id", "artistId"), + ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"), + ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"), DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"), CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), Images: gojaObjectString(obj, "images"), diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5fd2ad47..5e2cd984 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -6994,6 +6994,84 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Releases'** String get artistReleases; + + /// Button to clear selected fields for auto-fill + /// + /// In en, this message translates to: + /// **'None'** + String get editMetadataSelectNone; + + /// Button to retry every failed download in the queue + /// + /// In en, this message translates to: + /// **'Retry {count} failed'** + String queueRetryAllFailed(int count); + + /// Settings switch title for storing completed downloads in history + /// + /// In en, this message translates to: + /// **'Save download history'** + String get settingsSaveDownloadHistory; + + /// Settings switch subtitle for storing completed downloads in history + /// + /// In en, this message translates to: + /// **'Keep completed downloads in history and library views'** + String get settingsSaveDownloadHistorySubtitle; + + /// Confirmation dialog title shown before disabling download history + /// + /// In en, this message translates to: + /// **'Turn off download history?'** + String get dialogDisableHistoryTitle; + + /// Confirmation dialog message shown before disabling download history + /// + /// In en, this message translates to: + /// **'Existing history will be cleared. Downloaded files will not be deleted.'** + String get dialogDisableHistoryMessage; + + /// Confirmation action to disable download history and clear existing entries + /// + /// In en, this message translates to: + /// **'Turn off and clear'** + String get dialogDisableAndClear; + + /// Title and tooltip for finding the current collection in other services + /// + /// In en, this message translates to: + /// **'Open in other services'** + String get openInOtherServices; + + /// Empty state when no extensions can be searched for cross-service links + /// + /// In en, this message translates to: + /// **'No other compatible services'** + String get shareSheetNoExtensions; + + /// Cross-service share sheet row subtitle when a service has no match + /// + /// In en, this message translates to: + /// **'Not found'** + String get shareSheetNotFound; + + /// Tooltip for copying a cross-service link + /// + /// In en, this message translates to: + /// **'Copy link'** + String get shareSheetCopyLink; + + /// Snackbar after copying a cross-service link + /// + /// 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 a9384afb..6584222b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4229,4 +4229,49 @@ class AppLocalizationsDe extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 69950251..1459432d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4200,4 +4200,49 @@ class AppLocalizationsEn extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 e86aed72..92120a26 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4194,6 +4194,51 @@ class AppLocalizationsEs extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 0704f030..33cd3a5b 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4198,4 +4198,49 @@ class AppLocalizationsFr extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 9dfae906..febf0b15 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -4195,4 +4195,49 @@ class AppLocalizationsHi extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 9867fd2c..ecb41c71 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -4186,4 +4186,49 @@ class AppLocalizationsId extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'Tidak ada'; + + @override + String queueRetryAllFailed(int count) { + return 'Coba ulang $count gagal'; + } + + @override + String get settingsSaveDownloadHistory => 'Simpan riwayat unduhan'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Simpan unduhan selesai di riwayat dan tampilan pustaka'; + + @override + String get dialogDisableHistoryTitle => 'Matikan riwayat unduhan?'; + + @override + String get dialogDisableHistoryMessage => + 'Riwayat yang ada akan dihapus. File unduhan tidak akan dihapus.'; + + @override + String get dialogDisableAndClear => 'Matikan dan hapus'; + + @override + String get openInOtherServices => 'Buka di layanan lain'; + + @override + String get shareSheetNoExtensions => 'Tidak ada layanan lain yang kompatibel'; + + @override + String get shareSheetNotFound => 'Tidak ditemukan'; + + @override + 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 bc42d422..5fdd7505 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4182,4 +4182,49 @@ class AppLocalizationsJa extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 e42eef6c..b5276eb2 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4175,4 +4175,49 @@ class AppLocalizationsKo extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 36312341..18ea1f2b 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4195,4 +4195,49 @@ class AppLocalizationsNl extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 6209368b..ea5048c1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4194,6 +4194,51 @@ class AppLocalizationsPt extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 799141f4..d1461a7c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4254,4 +4254,49 @@ class AppLocalizationsRu extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 94e9a3e9..2f39d0ac 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -4221,4 +4221,49 @@ class AppLocalizationsTr extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 3d94cb1a..c3632d0a 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4254,4 +4254,49 @@ class AppLocalizationsUk extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 ef39d757..f63f58af 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4194,6 +4194,51 @@ class AppLocalizationsZh extends AppLocalizations { @override String get artistReleases => 'Releases'; + + @override + String get editMetadataSelectNone => 'None'; + + @override + String queueRetryAllFailed(int count) { + return 'Retry $count failed'; + } + + @override + String get settingsSaveDownloadHistory => 'Save download history'; + + @override + String get settingsSaveDownloadHistorySubtitle => + 'Keep completed downloads in history and library views'; + + @override + String get dialogDisableHistoryTitle => 'Turn off download history?'; + + @override + String get dialogDisableHistoryMessage => + 'Existing history will be cleared. Downloaded files will not be deleted.'; + + @override + String get dialogDisableAndClear => 'Turn off and clear'; + + @override + String get openInOtherServices => 'Open in other services'; + + @override + String get shareSheetNoExtensions => 'No other compatible services'; + + @override + String get shareSheetNotFound => 'Not found'; + + @override + 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 6589a320..245a7d45 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5494,5 +5494,65 @@ "artistReleases": "Releases", "@artistReleases": { "description": "Section header for all artist releases" + }, + "editMetadataSelectNone": "None", + "@editMetadataSelectNone": { + "description": "Button to clear selected fields for auto-fill" + }, + "queueRetryAllFailed": "Retry {count} failed", + "@queueRetryAllFailed": { + "description": "Button to retry every failed download in the queue", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "settingsSaveDownloadHistory": "Save download history", + "@settingsSaveDownloadHistory": { + "description": "Settings switch title for storing completed downloads in history" + }, + "settingsSaveDownloadHistorySubtitle": "Keep completed downloads in history and library views", + "@settingsSaveDownloadHistorySubtitle": { + "description": "Settings switch subtitle for storing completed downloads in history" + }, + "dialogDisableHistoryTitle": "Turn off download history?", + "@dialogDisableHistoryTitle": { + "description": "Confirmation dialog title shown before disabling download history" + }, + "dialogDisableHistoryMessage": "Existing history will be cleared. Downloaded files will not be deleted.", + "@dialogDisableHistoryMessage": { + "description": "Confirmation dialog message shown before disabling download history" + }, + "dialogDisableAndClear": "Turn off and clear", + "@dialogDisableAndClear": { + "description": "Confirmation action to disable download history and clear existing entries" + }, + "openInOtherServices": "Open in other services", + "@openInOtherServices": { + "description": "Title and tooltip for finding the current collection in other services" + }, + "shareSheetNoExtensions": "No other compatible services", + "@shareSheetNoExtensions": { + "description": "Empty state when no extensions can be searched for cross-service links" + }, + "shareSheetNotFound": "Not found", + "@shareSheetNotFound": { + "description": "Cross-service share sheet row subtitle when a service has no match" + }, + "shareSheetCopyLink": "Copy link", + "@shareSheetCopyLink": { + "description": "Tooltip for copying a cross-service link" + }, + "shareSheetLinkCopied": "{service} link copied", + "@shareSheetLinkCopied": { + "description": "Snackbar after copying a cross-service link", + "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 c16fafd1..bd30b129 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -4622,5 +4622,65 @@ "queueRateLimitMessage": "Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.", "@queueRateLimitMessage": { "description": "Explanation shown on a failed queue item when the download service rate limits requests" + }, + "editMetadataSelectNone": "Tidak ada", + "@editMetadataSelectNone": { + "description": "Button to clear selected fields for auto-fill" + }, + "queueRetryAllFailed": "Coba ulang {count} gagal", + "@queueRetryAllFailed": { + "description": "Button to retry every failed download in the queue", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "settingsSaveDownloadHistory": "Simpan riwayat unduhan", + "@settingsSaveDownloadHistory": { + "description": "Settings switch title for storing completed downloads in history" + }, + "settingsSaveDownloadHistorySubtitle": "Simpan unduhan selesai di riwayat dan tampilan pustaka", + "@settingsSaveDownloadHistorySubtitle": { + "description": "Settings switch subtitle for storing completed downloads in history" + }, + "dialogDisableHistoryTitle": "Matikan riwayat unduhan?", + "@dialogDisableHistoryTitle": { + "description": "Confirmation dialog title shown before disabling download history" + }, + "dialogDisableHistoryMessage": "Riwayat yang ada akan dihapus. File unduhan tidak akan dihapus.", + "@dialogDisableHistoryMessage": { + "description": "Confirmation dialog message shown before disabling download history" + }, + "dialogDisableAndClear": "Matikan dan hapus", + "@dialogDisableAndClear": { + "description": "Confirmation action to disable download history and clear existing entries" + }, + "openInOtherServices": "Buka di layanan lain", + "@openInOtherServices": { + "description": "Title and tooltip for finding the current collection in other services" + }, + "shareSheetNoExtensions": "Tidak ada layanan lain yang kompatibel", + "@shareSheetNoExtensions": { + "description": "Empty state when no extensions can be searched for cross-service links" + }, + "shareSheetNotFound": "Tidak ditemukan", + "@shareSheetNotFound": { + "description": "Cross-service share sheet row subtitle when a service has no match" + }, + "shareSheetCopyLink": "Salin tautan", + "@shareSheetCopyLink": { + "description": "Tooltip for copying a cross-service link" + }, + "shareSheetLinkCopied": "Tautan {service} disalin", + "@shareSheetLinkCopied": { + "description": "Snackbar after copying a cross-service link", + "placeholders": { + "service": {} + } + }, + "shareSheetOpen": "Buka", + "@shareSheetOpen": { + "description": "Tooltip for opening a cross-service link inside the app" } } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e6cffd9c..2c55540b 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -91,6 +91,7 @@ class AppSettings { final bool deduplicateDownloads; // Skip downloading tracks already present in history + final bool saveDownloadHistory; // Record completed downloads in local history const AppSettings({ this.defaultService = '', @@ -152,6 +153,7 @@ class AppSettings { this.musixmatchLanguage = '', this.lastSeenVersion = '', this.deduplicateDownloads = true, + this.saveDownloadHistory = true, }); AppSettings copyWith({ @@ -217,6 +219,7 @@ class AppSettings { String? musixmatchLanguage, String? lastSeenVersion, bool? deduplicateDownloads, + bool? saveDownloadHistory, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -300,6 +303,7 @@ class AppSettings { musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage, lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion, deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads, + saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index d5dec76c..e4216991 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -82,6 +82,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '', lastSeenVersion: json['lastSeenVersion'] as String? ?? '', deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true, + saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true, ); Map _$AppSettingsToJson( @@ -147,4 +148,5 @@ Map _$AppSettingsToJson( 'musixmatchLanguage': instance.musixmatchLanguage, 'lastSeenVersion': instance.lastSeenVersion, 'deduplicateDownloads': instance.deduplicateDownloads, + 'saveDownloadHistory': instance.saveDownloadHistory, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 15391e68..44bbb990 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -4023,6 +4023,57 @@ class DownloadQueueNotifier extends Notifier { } } + void retryAllFailed() { + final failedIds = state.items + .where( + (item) => + item.status == DownloadStatus.failed || + item.status == DownloadStatus.skipped, + ) + .map((item) => item.id) + .toSet(); + if (failedIds.isEmpty) { + _log.d('retryAllFailed: no failed downloads to retry'); + return; + } + + _log.i('Retrying ${failedIds.length} failed download(s)'); + _locallyCancelledItemIds.removeAll(failedIds); + _pausePendingItemIds.removeAll(failedIds); + + for (final item in state.items) { + if (!failedIds.contains(item.id)) continue; + final rgKey = _albumRgKey(item.track); + final rgAcc = _albumRgData[rgKey]; + if (rgAcc == null) continue; + rgAcc.entries.removeWhere((entry) => entry.trackId == item.track.id); + if (rgAcc.entries.isEmpty) { + _albumRgData.remove(rgKey); + } + } + + final items = state.items + .map((item) { + if (!failedIds.contains(item.id)) return item; + return item.copyWith( + status: DownloadStatus.queued, + progress: 0, + speedMBps: 0, + bytesReceived: 0, + bytesTotal: 0, + error: null, + ); + }) + .toList(growable: false); + + state = state.copyWith(items: items, isPaused: false); + _saveQueueToStorage(); + + if (!state.isProcessing) { + Future.microtask(() => _processQueue()); + } + } + void removeItem(String id) { final removedItem = state.items.where((item) => item.id == id).firstOrNull; _locallyCancelledItemIds.remove(id); @@ -5336,6 +5387,7 @@ class DownloadQueueNotifier extends Notifier { DownloadRequestPayload.nativeWorkerContractVersion, 'run_id': runId, 'created_at': DateTime.now().toIso8601String(), + 'save_download_history': settings.saveDownloadHistory, }, ); @@ -5769,22 +5821,26 @@ class DownloadQueueNotifier extends Notifier { progress: 1.0, filePath: filePath, ); - final historyItem = result['history_item']; - if (historyItem is Map) { - try { - ref - .read(downloadHistoryProvider.notifier) - .adoptNativeHistoryItem( - DownloadHistoryItem.fromJson( - Map.from(historyItem), - ), - ); - } catch (e) { - _log.w('Failed to adopt native history item: $e'); + if (settings.saveDownloadHistory) { + final historyItem = result['history_item']; + if (historyItem is Map) { + try { + ref + .read(downloadHistoryProvider.notifier) + .adoptNativeHistoryItem( + DownloadHistoryItem.fromJson( + Map.from(historyItem), + ), + ); + } catch (e) { + _log.w('Failed to adopt native history item: $e'); + await ref + .read(downloadHistoryProvider.notifier) + .reloadFromStorage(); + } + } else if (result['history_written'] == true) { await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } - } else if (result['history_written'] == true) { - await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } _completedInSession++; await _notificationService.showDownloadComplete( @@ -5989,51 +6045,53 @@ class DownloadQueueNotifier extends Notifier { backendComposer, ); - ref - .read(downloadHistoryProvider.notifier) - .addToHistory( - DownloadHistoryItem( - id: item.id, - trackName: historyTitle, - artistName: historyArtist, - albumName: historyAlbum, - albumArtist: normalizeOptionalString(trackToDownload.albumArtist), - coverUrl: normalizeCoverReference(trackToDownload.coverUrl), - filePath: filePath, - storageMode: context.storageMode, - downloadTreeUri: context.storageMode == 'saf' - ? context.downloadTreeUri - : null, - safRelativeDir: context.storageMode == 'saf' - ? context.safRelativeDir - : null, - safFileName: context.storageMode == 'saf' - ? ((resultSafFileName != null && resultSafFileName.isNotEmpty) - ? resultSafFileName - : context.safFileName) - : null, - safRepaired: false, - service: result['service'] as String? ?? item.service, - downloadedAt: DateTime.now(), - isrc: historyIsrc, - spotifyId: trackToDownload.id, - trackNumber: historyTrackNumber, - totalTracks: historyTotalTracks, - discNumber: historyDiscNumber, - totalDiscs: historyTotalDiscs, - duration: trackToDownload.duration, - releaseDate: historyReleaseDate, - quality: actualQuality, - bitDepth: isLossyOutput ? null : actualBitDepth, - sampleRate: isLossyOutput ? null : actualSampleRate, - bitrate: isLossyOutput ? actualBitrate : null, - format: historyFormat, - genre: normalizeOptionalString(backendGenre), - composer: historyComposer, - label: normalizeOptionalString(backendLabel), - copyright: normalizeOptionalString(backendCopyright), - ), - ); + if (settings.saveDownloadHistory) { + ref + .read(downloadHistoryProvider.notifier) + .addToHistory( + DownloadHistoryItem( + id: item.id, + trackName: historyTitle, + artistName: historyArtist, + albumName: historyAlbum, + albumArtist: normalizeOptionalString(trackToDownload.albumArtist), + coverUrl: normalizeCoverReference(trackToDownload.coverUrl), + filePath: filePath, + storageMode: context.storageMode, + downloadTreeUri: context.storageMode == 'saf' + ? context.downloadTreeUri + : null, + safRelativeDir: context.storageMode == 'saf' + ? context.safRelativeDir + : null, + safFileName: context.storageMode == 'saf' + ? ((resultSafFileName != null && resultSafFileName.isNotEmpty) + ? resultSafFileName + : context.safFileName) + : null, + safRepaired: false, + service: result['service'] as String? ?? item.service, + downloadedAt: DateTime.now(), + isrc: historyIsrc, + spotifyId: trackToDownload.id, + trackNumber: historyTrackNumber, + totalTracks: historyTotalTracks, + discNumber: historyDiscNumber, + totalDiscs: historyTotalDiscs, + duration: trackToDownload.duration, + releaseDate: historyReleaseDate, + quality: actualQuality, + bitDepth: isLossyOutput ? null : actualBitDepth, + sampleRate: isLossyOutput ? null : actualSampleRate, + bitrate: isLossyOutput ? actualBitrate : null, + format: historyFormat, + genre: normalizeOptionalString(backendGenre), + composer: historyComposer, + label: normalizeOptionalString(backendLabel), + copyright: normalizeOptionalString(backendCopyright), + ), + ); + } removeItem(item.id); } @@ -8659,47 +8717,51 @@ class DownloadQueueNotifier extends Notifier { backendComposer, ); - ref - .read(downloadHistoryProvider.notifier) - .addToHistory( - DownloadHistoryItem( - id: item.id, - trackName: historyTitle, - artistName: historyArtist, - albumName: historyAlbum, - albumArtist: historyAlbumArtist, - coverUrl: normalizeCoverReference(trackToDownload.coverUrl), - filePath: filePath, - storageMode: effectiveSafMode ? 'saf' : 'app', - downloadTreeUri: effectiveSafMode - ? settings.downloadTreeUri - : null, - safRelativeDir: effectiveSafMode ? effectiveOutputDir : null, - safFileName: effectiveSafMode - ? (finalSafFileName ?? safFileName) - : null, - safRepaired: false, - service: result['service'] as String? ?? item.service, - downloadedAt: DateTime.now(), - isrc: historyIsrc, - spotifyId: trackToDownload.id, - trackNumber: historyTrackNumber, - totalTracks: historyTotalTracks, - discNumber: historyDiscNumber, - totalDiscs: historyTotalDiscs, - duration: trackToDownload.duration, - releaseDate: historyReleaseDate, - quality: actualQuality, - bitDepth: historyBitDepth, - sampleRate: historySampleRate, - bitrate: historyBitrate, - format: finalFormat, - genre: effectiveGenre, - composer: historyComposer, - label: effectiveLabel, - copyright: effectiveCopyright, - ), - ); + if (settings.saveDownloadHistory) { + ref + .read(downloadHistoryProvider.notifier) + .addToHistory( + DownloadHistoryItem( + id: item.id, + trackName: historyTitle, + artistName: historyArtist, + albumName: historyAlbum, + albumArtist: historyAlbumArtist, + coverUrl: normalizeCoverReference(trackToDownload.coverUrl), + filePath: filePath, + storageMode: effectiveSafMode ? 'saf' : 'app', + downloadTreeUri: effectiveSafMode + ? settings.downloadTreeUri + : null, + safRelativeDir: effectiveSafMode + ? effectiveOutputDir + : null, + safFileName: effectiveSafMode + ? (finalSafFileName ?? safFileName) + : null, + safRepaired: false, + service: result['service'] as String? ?? item.service, + downloadedAt: DateTime.now(), + isrc: historyIsrc, + spotifyId: trackToDownload.id, + trackNumber: historyTrackNumber, + totalTracks: historyTotalTracks, + discNumber: historyDiscNumber, + totalDiscs: historyTotalDiscs, + duration: trackToDownload.duration, + releaseDate: historyReleaseDate, + quality: actualQuality, + bitDepth: historyBitDepth, + sampleRate: historySampleRate, + bitrate: historyBitrate, + format: finalFormat, + genre: effectiveGenre, + composer: historyComposer, + label: effectiveLabel, + copyright: effectiveCopyright, + ), + ); + } removeItem(item.id); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index d5912c68..949c8360 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -600,6 +600,11 @@ class SettingsNotifier extends Notifier { state = state.copyWith(deduplicateDownloads: enabled); _saveSettings(); } + + void setSaveDownloadHistory(bool enabled) { + state = state.copyWith(saveDownloadHistory: enabled); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 5862f38f..cfb85b1f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -21,6 +21,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart'; class _AlbumCache { static final Map _cache = {}; @@ -566,6 +567,8 @@ class _AlbumScreenState extends ConsumerState { children: [ _buildLoveAllButton(), const SizedBox(width: 12), + _buildShareButton(context, tracks, artistName), + const SizedBox(width: 12), FilledButton.icon( onPressed: () => _downloadAll(context), icon: Icon(Icons.download, size: 18), @@ -846,6 +849,45 @@ class _AlbumScreenState extends ConsumerState { ); } + Widget _buildShareButton( + BuildContext context, + List tracks, + String? artistName, + ) { + final sourceExtensionId = _directMetadataProviderId() ?? ''; + final resolvedArtists = + artistName ?? + tracks.firstOrNull?.albumArtist ?? + tracks.firstOrNull?.artistName ?? + ''; + + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: () => CrossExtensionShareSheet.show( + context, + name: widget.albumName, + artists: resolvedArtists, + type: 'album', + sourceExtensionId: sourceExtensionId, + ), + icon: const Icon(Icons.open_in_new_rounded, size: 22), + color: Colors.white, + tooltip: context.l10n.openInOtherServices, + padding: EdgeInsets.zero, + ), + ); + } + Future _loveAll(List tracks) async { final notifier = ref.read(libraryCollectionsProvider.notifier); final state = ref.read(libraryCollectionsProvider); diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index acef1930..e8d18350 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -23,6 +23,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart'; class _ArtistCache { static final Map _cache = {}; @@ -1421,10 +1422,31 @@ class _ArtistScreenState extends ConsumerState { ), ); }), - ), - ), - ), - ], + ), + ), + ), + const SizedBox(width: 12), + Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: () => CrossExtensionShareSheet.show( + context, + name: widget.artistName, + artists: '', + type: 'artist', + sourceExtensionId: _directMetadataProviderId() ?? '', + ), + icon: const Icon(Icons.open_in_new_rounded, size: 24), + color: Colors.black87, + tooltip: context.l10n.openInOtherServices, + ), + ), + ], ); } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 8b54075c..9413049d 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -3018,24 +3018,47 @@ class _QueueTabState extends ConsumerState { final queueCount = ref.watch( downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length), ); + final failedCount = ref.watch( + downloadQueueProvider.select((state) => state.failedCount), + ); + final isProcessing = ref.watch( + downloadQueueProvider.select((state) => state.isProcessing), + ); if (queueCount == 0) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - context.l10n.queueDownloadingCount(queueCount), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + Row( + children: [ + Text( + context.l10n.queueDownloadingCount(queueCount), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + _buildPauseResumeButton(context, ref, colorScheme), + const SizedBox(width: 4), + _buildClearAllButton(context, ref, colorScheme), + ], ), - const Spacer(), - _buildPauseResumeButton(context, ref, colorScheme), - const SizedBox(width: 4), - _buildClearAllButton(context, ref, colorScheme), + if (failedCount > 0 && !isProcessing) ...[ + const SizedBox(height: 6), + Align( + alignment: Alignment.centerLeft, + child: _buildRetryAllFailedButton( + context, + ref, + colorScheme, + failedCount, + ), + ), + ], ], ), ), @@ -4007,6 +4030,24 @@ class _QueueTabState extends ConsumerState { ); } + Widget _buildRetryAllFailedButton( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + int failedCount, + ) { + return TextButton.icon( + onPressed: () => + ref.read(downloadQueueProvider.notifier).retryAllFailed(), + icon: const Icon(Icons.replay_rounded, size: 18), + label: Text(context.l10n.queueRetryAllFailed(failedCount)), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + foregroundColor: colorScheme.primary, + ), + ); + } + Future _showClearAllDialog( BuildContext context, WidgetRef ref, diff --git a/lib/screens/settings/app_settings_page.dart b/lib/screens/settings/app_settings_page.dart index a37cbbee..5e746686 100644 --- a/lib/screens/settings/app_settings_page.dart +++ b/lib/screens/settings/app_settings_page.dart @@ -114,6 +114,33 @@ class AppSettingsPage extends ConsumerWidget { subtitle: context.l10n.optionsClearHistorySubtitle, onTap: () => _showClearHistoryDialog(context, ref, colorScheme), + ), + SettingsSwitchItem( + icon: Icons.history_toggle_off_outlined, + title: context.l10n.settingsSaveDownloadHistory, + subtitle: context.l10n.settingsSaveDownloadHistorySubtitle, + value: settings.saveDownloadHistory, + onChanged: (enabled) { + if (enabled) { + ref + .read(settingsProvider.notifier) + .setSaveDownloadHistory(true); + return; + } + + final hasHistory = ref + .read(downloadHistoryProvider) + .items + .isNotEmpty; + if (hasHistory) { + _showDisableHistoryDialog(context, ref, colorScheme); + return; + } + + ref + .read(settingsProvider.notifier) + .setSaveDownloadHistory(false); + }, showDivider: false, ), ], @@ -148,6 +175,40 @@ class AppSettingsPage extends ConsumerWidget { ); } + void _showDisableHistoryDialog( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.dialogDisableHistoryTitle), + content: Text(context.l10n.dialogDisableHistoryMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.dialogCancel), + ), + TextButton( + onPressed: () { + ref.read(downloadHistoryProvider.notifier).clearHistory(); + ref.read(settingsProvider.notifier).setSaveDownloadHistory(false); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarHistoryCleared)), + ); + }, + child: Text( + context.l10n.dialogDisableAndClear, + style: TextStyle(color: colorScheme.error), + ), + ), + ], + ), + ); + } + void _showClearHistoryDialog( BuildContext context, WidgetRef ref, diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 8593bb12..d8bfb2d2 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -350,6 +350,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } + void _selectNoFields() { + setState(_autoFillFields.clear); + } + String _normalizeMetadataText(String value) { final collapsed = value .toLowerCase() @@ -1474,6 +1478,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { onTap: _selectEmptyFields, cs: cs, ), + const SizedBox(width: 8), + _quickSelectButton( + label: context.l10n.editMetadataSelectNone, + onTap: _selectNoFields, + cs: cs, + ), ], ), ), diff --git a/lib/services/cross_extension_share_service.dart b/lib/services/cross_extension_share_service.dart new file mode 100644 index 00000000..ce19a0b2 --- /dev/null +++ b/lib/services/cross_extension_share_service.dart @@ -0,0 +1,52 @@ +import 'package:spotiflac_android/services/platform_bridge.dart'; + +class CrossExtensionShareResult { + final String extensionId; + final String displayName; + final bool found; + final String? url; + final String? itemName; + final String? itemArtists; + final String? error; + + const CrossExtensionShareResult({ + required this.extensionId, + required this.displayName, + required this.found, + this.url, + this.itemName, + this.itemArtists, + this.error, + }); + + factory CrossExtensionShareResult.fromJson(Map json) { + return CrossExtensionShareResult( + extensionId: json['extension_id'] as String? ?? '', + displayName: json['display_name'] as String? ?? '', + found: json['found'] as bool? ?? false, + url: json['url'] as String?, + itemName: json['item_name'] as String?, + itemArtists: json['item_artists'] as String?, + error: json['error'] as String?, + ); + } +} + +class CrossExtensionShareService { + const CrossExtensionShareService(); + + Future> findAcrossExtensions({ + required String name, + required String artists, + required String type, + required String sourceExtensionId, + }) async { + final results = await PlatformBridge.findCollectionAcrossExtensions( + name: name, + artists: artists, + type: type, + sourceExtensionId: sourceExtensionId, + ); + return results.map(CrossExtensionShareResult.fromJson).toList(); + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 196ad8d3..af40dea4 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -236,6 +237,7 @@ class NotificationService { bool alreadyInLibrary = false, }) async { if (!_isInitialized) await initialize(); + unawaited(HapticFeedback.mediumImpact()); String title; if (alreadyInLibrary) { @@ -286,6 +288,11 @@ class NotificationService { }) async { if (!_isInitialized) await initialize(); if (completedCount <= 0 && failedCount <= 0) return; + unawaited( + failedCount > 0 + ? HapticFeedback.heavyImpact() + : HapticFeedback.mediumImpact(), + ); final title = failedCount > 0 ? (_l10n?.notifDownloadsFinished(completedCount, failedCount) ?? @@ -330,6 +337,7 @@ class NotificationService { Future showQueueCanceled({required int canceledCount}) async { if (!_isInitialized) await initialize(); if (canceledCount <= 0) return; + unawaited(HapticFeedback.lightImpact()); final title = _l10n?.notifDownloadsCanceledTitle ?? 'Downloads canceled'; final body = diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 4ee011d9..a5ec65fe 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1309,6 +1309,25 @@ class PlatformBridge { return _decodeMapListResult(result, 'searchTracksWithMetadataProviders'); } + static Future>> findCollectionAcrossExtensions({ + required String name, + required String artists, + required String type, + required String sourceExtensionId, + }) async { + final requestJson = jsonEncode({ + 'name': name, + 'artists': artists, + 'type': type, + 'source_extension_id': sourceExtensionId, + }); + final result = await _channel.invokeMethod( + 'findCollectionAcrossExtensions', + requestJson, + ); + return _decodeMapListResult(result, 'findCollectionAcrossExtensions'); + } + static Future cleanupExtensions() async { _log.d('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions'); diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index bdff9df2..3e911e67 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -31,6 +31,12 @@ class ShareIntentService { return url; } + void injectUrl(String url) { + if (url.isEmpty) return; + _pendingUrl = url; + _sharedUrlController.add(url); + } + Future initialize() async { if (_initialized) return; _initialized = true; diff --git a/lib/widgets/app_announcement_dialog.dart b/lib/widgets/app_announcement_dialog.dart index 61528041..4b742157 100644 --- a/lib/widgets/app_announcement_dialog.dart +++ b/lib/widgets/app_announcement_dialog.dart @@ -36,15 +36,14 @@ class AppAnnouncementDialog extends StatelessWidget { } catch (_) { launched = false; } + if (!context.mounted) return; if (!launched) { _showCtaOpenFailed(context); return; } onDismiss(); - if (context.mounted) { - Navigator.pop(context); - } + Navigator.pop(context); } void _showCtaOpenFailed(BuildContext context) { diff --git a/lib/widgets/cross_extension_share_sheet.dart b/lib/widgets/cross_extension_share_sheet.dart new file mode 100644 index 00000000..689408cc --- /dev/null +++ b/lib/widgets/cross_extension_share_sheet.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/cross_extension_share_service.dart'; +import 'package:spotiflac_android/services/share_intent_service.dart'; + +class CrossExtensionShareSheet extends StatefulWidget { + final String name; + final String artists; + final String type; + final String sourceExtensionId; + + const CrossExtensionShareSheet({ + super.key, + required this.name, + required this.artists, + required this.type, + required this.sourceExtensionId, + }); + + static Future show( + BuildContext context, { + required String name, + required String artists, + required String type, + required String sourceExtensionId, + }) { + final colorScheme = Theme.of(context).colorScheme; + return showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (_) => CrossExtensionShareSheet( + name: name, + artists: artists, + type: type, + sourceExtensionId: sourceExtensionId, + ), + ); + } + + @override + State createState() => + _CrossExtensionShareSheetState(); +} + +class _CrossExtensionShareSheetState extends State { + late final Future> _future; + + @override + void initState() { + super.initState(); + _future = const CrossExtensionShareService() + .findAcrossExtensions( + name: widget.name, + artists: widget.artists, + type: widget.type, + sourceExtensionId: widget.sourceExtensionId, + ) + .then((results) { + final sorted = [...results]; + sorted.sort((a, b) { + if (a.found != b.found) return a.found ? -1 : 1; + return a.displayName.compareTo(b.displayName); + }); + return sorted; + }); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.82, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 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), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), + child: Text( + context.l10n.openInOtherServices, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + widget.artists.isNotEmpty + ? '${widget.name} - ${widget.artists}' + : widget.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Flexible( + child: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const SizedBox( + height: 180, + child: Center(child: CircularProgressIndicator()), + ); + } + + final results = snapshot.data ?? const []; + if (results.isEmpty) { + return SizedBox( + height: 180, + child: Center( + child: Text( + context.l10n.shareSheetNoExtensions, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + return ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(12, 4, 12, 16), + itemBuilder: (context, index) { + return _CrossExtensionShareTile(result: results[index]); + }, + separatorBuilder: (_, _) => + const Divider(height: 1, indent: 72), + itemCount: results.length, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _CrossExtensionShareTile extends StatelessWidget { + final CrossExtensionShareResult result; + + const _CrossExtensionShareTile({required this.result}); + + @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; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, 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, + ), + ), + title: Text( + result.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + ), + subtitle: Text( + hasUrl + ? (result.itemName?.isNotEmpty == true ? result.itemName! : url) + : context.l10n.shareSheetNotFound, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: hasUrl ? colorScheme.primary : 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); + }, + ), + ], + ) + : null, + ); + } +}