mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-04 05:38:12 +02:00
feat: rebuild cross-extension sharing and queue controls
Co-authored-by: Amonoman <musaauron87@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user