feat: rebuild cross-extension sharing and queue controls

Co-authored-by: Amonoman <musaauron87@gmail.com>
This commit is contained in:
zarzet
2026-05-31 21:59:39 +07:00
parent f52527a41b
commit ffdaf14ba5
38 changed files with 1850 additions and 122 deletions
@@ -690,7 +690,8 @@ class DownloadService : Service() {
request.itemId,
request.requestJson,
request.itemJson,
result
result,
settingsJson
) {
nativeWorkerCancelRequested ||
nativeWorkerPaused ||
@@ -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<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
@@ -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)
+286
View File
@@ -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 ""
}
+5
View File
@@ -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,
+10
View File
@@ -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"),
+78
View File
@@ -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
+45
View File
@@ -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';
}
+45
View File
@@ -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';
}
+45
View File
@@ -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`).
+45
View File
@@ -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';
}
+45
View File
@@ -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';
}
+45
View File
@@ -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';
}
+45
View File
@@ -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';
}
+45
View File
@@ -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';
}
+45
View File
@@ -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';
}
+45
View File
@@ -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`).
+45
View File
@@ -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';
}
+45
View File
@@ -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';
}
+45
View File
@@ -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';
}
+45
View File
@@ -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`).
+60
View File
@@ -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"
}
}
+60
View File
@@ -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"
}
}
+4
View File
@@ -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,
);
}
+2
View File
@@ -82,6 +82,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
@@ -147,4 +148,5 @@ Map<String, dynamic> _$AppSettingsToJson(
'musixmatchLanguage': instance.musixmatchLanguage,
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'saveDownloadHistory': instance.saveDownloadHistory,
};
+162 -100
View File
@@ -4023,6 +4023,57 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
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<DownloadQueueState> {
DownloadRequestPayload.nativeWorkerContractVersion,
'run_id': runId,
'created_at': DateTime.now().toIso8601String(),
'save_download_history': settings.saveDownloadHistory,
},
);
@@ -5769,22 +5821,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: 1.0,
filePath: filePath,
);
final historyItem = result['history_item'];
if (historyItem is Map) {
try {
ref
.read(downloadHistoryProvider.notifier)
.adoptNativeHistoryItem(
DownloadHistoryItem.fromJson(
Map<String, dynamic>.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<String, dynamic>.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<DownloadQueueState> {
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<DownloadQueueState> {
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);
}
+5
View File
@@ -600,6 +600,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(deduplicateDownloads: enabled);
_saveSettings();
}
void setSaveDownloadHistory(bool enabled) {
state = state.copyWith(saveDownloadHistory: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+42
View File
@@ -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<String, _CacheEntry> _cache = {};
@@ -566,6 +567,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
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<AlbumScreen> {
);
}
Widget _buildShareButton(
BuildContext context,
List<Track> 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<void> _loveAll(List<Track> tracks) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
final state = ref.read(libraryCollectionsProvider);
+26 -4
View File
@@ -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<String, _CacheEntry> _cache = {};
@@ -1421,10 +1422,31 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
);
}),
),
),
),
],
),
),
),
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,
),
),
],
);
}
+51 -10
View File
@@ -3018,24 +3018,47 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
);
}
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<void> _showClearAllDialog(
BuildContext context,
WidgetRef ref,
@@ -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<void>(
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,
@@ -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,
),
],
),
),
@@ -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<String, dynamic> 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<List<CrossExtensionShareResult>> 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();
}
}
+8
View File
@@ -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<void> showQueueCanceled({required int canceledCount}) async {
if (!_isInitialized) await initialize();
if (canceledCount <= 0) return;
unawaited(HapticFeedback.lightImpact());
final title = _l10n?.notifDownloadsCanceledTitle ?? 'Downloads canceled';
final body =
+19
View File
@@ -1309,6 +1309,25 @@ class PlatformBridge {
return _decodeMapListResult(result, 'searchTracksWithMetadataProviders');
}
static Future<List<Map<String, dynamic>>> 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<void> cleanupExtensions() async {
_log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions');
+6
View File
@@ -31,6 +31,12 @@ class ShareIntentService {
return url;
}
void injectUrl(String url) {
if (url.isEmpty) return;
_pendingUrl = url;
_sharedUrlController.add(url);
}
Future<void> initialize() async {
if (_initialized) return;
_initialized = true;
+2 -3
View File
@@ -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) {
@@ -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<void> show(
BuildContext context, {
required String name,
required String artists,
required String type,
required String sourceExtensionId,
}) {
final colorScheme = Theme.of(context).colorScheme;
return showModalBottomSheet<void>(
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<CrossExtensionShareSheet> createState() =>
_CrossExtensionShareSheetState();
}
class _CrossExtensionShareSheetState extends State<CrossExtensionShareSheet> {
late final Future<List<CrossExtensionShareResult>> _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<List<CrossExtensionShareResult>>(
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,
);
}
}