mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce66b9e03 | |||
| 8e68af79aa | |||
| 6246e6e821 | |||
| 421d5ffdc8 | |||
| b82dabe316 | |||
| ffdaf14ba5 | |||
| f52527a41b | |||
| 56a89c5fc6 | |||
| 4f5163be01 | |||
| 822c094c8c | |||
| 0952b76e11 | |||
| 7291dbd9e2 | |||
| d005e2e2e7 |
@@ -9,6 +9,9 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
plugins:
|
||||
riverpod_lint: 3.1.4-dev.3
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
@@ -19,9 +22,6 @@ analyzer:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
plugins:
|
||||
- custom_lint
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -44,9 +44,5 @@ linter:
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
|
||||
custom_lint:
|
||||
rules:
|
||||
- avoid_public_notifier_properties
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -120,8 +120,8 @@ dependencies {
|
||||
// Include all AAR and JAR files from libs folder
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||
|
||||
@@ -690,7 +690,8 @@ class DownloadService : Service() {
|
||||
request.itemId,
|
||||
request.requestJson,
|
||||
request.itemJson,
|
||||
result
|
||||
result,
|
||||
settingsJson
|
||||
) {
|
||||
nativeWorkerCancelRequested ||
|
||||
nativeWorkerPaused ||
|
||||
|
||||
@@ -1037,6 +1037,48 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
|
||||
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
|
||||
* in the same parent directory. Used by re-enrich when the user's lyrics
|
||||
* mode requests an external/both sidecar. Best-effort: failures are logged
|
||||
* and swallowed so they never abort the metadata enrichment itself.
|
||||
*/
|
||||
private fun writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
|
||||
if (lrcContent.isBlank()) return false
|
||||
try {
|
||||
val parent = safParentDir(audioUri) ?: run {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
|
||||
return false
|
||||
}
|
||||
val audioName = try {
|
||||
DocumentFile.fromSingleUri(this, audioUri)?.name
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return false
|
||||
val baseName = audioName.substringBeforeLast('.', audioName)
|
||||
val lrcName = "$baseName.lrc"
|
||||
|
||||
val target = createOrReuseDocumentFile(
|
||||
parent,
|
||||
"application/octet-stream",
|
||||
lrcName
|
||||
) ?: run {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
|
||||
return false
|
||||
}
|
||||
|
||||
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
|
||||
output.write(lrcContent.toByteArray(Charsets.UTF_8))
|
||||
} ?: return false
|
||||
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the audio filename referenced by a CUE sheet file.
|
||||
* Reads the FILE "name" TYPE line from the .cue text.
|
||||
@@ -2604,6 +2646,23 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"writeSafSidecarLrc" -> {
|
||||
val safUri = call.argument<String>("saf_uri") ?: ""
|
||||
val lyrics = call.argument<String>("lyrics") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val uri = Uri.parse(safUri)
|
||||
if (writeSafSidecarLrc(uri, lyrics)) {
|
||||
"""{"success":true}"""
|
||||
} else {
|
||||
"""{"success":false,"error":"Failed to write LRC sidecar"}"""
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadCoverToFile" -> {
|
||||
val coverUrl = call.argument<String>("cover_url") ?: ""
|
||||
val outputPath = call.argument<String>("output_path") ?: ""
|
||||
@@ -2761,6 +2820,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
if (!writeUriFromPath(uri, tempPath)) {
|
||||
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
|
||||
}
|
||||
if (obj.optBoolean("write_external_lrc", false)) {
|
||||
writeSafSidecarLrc(uri, obj.optString("lyrics", ""))
|
||||
}
|
||||
raw
|
||||
} catch (e: Exception) {
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
@@ -3095,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)
|
||||
@@ -1081,10 +1087,11 @@ object NativeDownloadFinalizer {
|
||||
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
|
||||
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
|
||||
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
|
||||
val lyrics = resolveLyricsLrc(input)
|
||||
val shouldEmbedLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||
(input.request.optString("lyrics_mode", "embed") == "embed" ||
|
||||
input.request.optString("lyrics_mode", "embed") == "both") &&
|
||||
val lyricsMode = input.request.optString("lyrics_mode", "embed")
|
||||
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||
(lyricsMode == "embed" || lyricsMode == "both")
|
||||
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
|
||||
val shouldEmbedLyrics = shouldResolveLyrics &&
|
||||
lyrics.isNotBlank() &&
|
||||
lyrics != "[instrumental:true]"
|
||||
if (format == "flac") {
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 37191956
|
||||
"size": 34915749
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"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"`
|
||||
}
|
||||
|
||||
var crossExtensionShareResultCache = struct {
|
||||
sync.RWMutex
|
||||
entries map[string]string
|
||||
order []string
|
||||
}{
|
||||
entries: make(map[string]string),
|
||||
}
|
||||
|
||||
const crossExtensionShareResultCacheLimit = 128
|
||||
|
||||
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
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)
|
||||
}
|
||||
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
|
||||
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
query := req.Name
|
||||
if req.Artists != "" {
|
||||
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
|
||||
}
|
||||
response := string(data)
|
||||
if crossExtensionShareResultsCacheable(results) {
|
||||
setCrossExtensionShareCache(cacheKey, response)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
|
||||
providerKeys := make([]string, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider == nil || provider.extension == nil {
|
||||
continue
|
||||
}
|
||||
ext := provider.extension
|
||||
displayName := ""
|
||||
if ext.Manifest != nil {
|
||||
displayName = ext.Manifest.DisplayName
|
||||
}
|
||||
providerKeys = append(providerKeys, strings.Join([]string{
|
||||
strings.TrimSpace(ext.ID),
|
||||
strings.TrimSpace(displayName),
|
||||
strings.TrimSpace(ext.SourceDir),
|
||||
}, "\x1f"))
|
||||
}
|
||||
sort.Strings(providerKeys)
|
||||
|
||||
return strings.Join([]string{
|
||||
normalizeLooseTitle(itemType),
|
||||
normalizeLooseTitle(name),
|
||||
normalizeLooseArtistName(artists),
|
||||
strings.TrimSpace(sourceExtensionID),
|
||||
strings.Join(providerKeys, "\x1e"),
|
||||
}, "\x1d")
|
||||
}
|
||||
|
||||
func getCrossExtensionShareCache(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
crossExtensionShareResultCache.RLock()
|
||||
defer crossExtensionShareResultCache.RUnlock()
|
||||
return crossExtensionShareResultCache.entries[key]
|
||||
}
|
||||
|
||||
func setCrossExtensionShareCache(key string, value string) {
|
||||
if key == "" || value == "" {
|
||||
return
|
||||
}
|
||||
crossExtensionShareResultCache.Lock()
|
||||
defer crossExtensionShareResultCache.Unlock()
|
||||
|
||||
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
|
||||
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
|
||||
}
|
||||
crossExtensionShareResultCache.entries[key] = value
|
||||
|
||||
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
|
||||
oldest := crossExtensionShareResultCache.order[0]
|
||||
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
|
||||
delete(crossExtensionShareResultCache.entries, oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Found {
|
||||
continue
|
||||
}
|
||||
errText := strings.ToLower(strings.TrimSpace(result.Error))
|
||||
if errText == "" ||
|
||||
errText == "no results" ||
|
||||
errText == "unsupported collection type" ||
|
||||
strings.HasSuffix(errText, " not found") ||
|
||||
strings.Contains(errText, "found without shareable link") {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func findCollectionForExtension(
|
||||
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 := searchCollectionCandidates(provider, itemType, query)
|
||||
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 = collectionArtistName(*best)
|
||||
} else {
|
||||
result.ItemName = collectionAlbumName(*best)
|
||||
result.ItemArtists = best.Artists
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
|
||||
filter := ""
|
||||
switch itemType {
|
||||
case "album":
|
||||
filter = "albums"
|
||||
case "artist":
|
||||
filter = "artists"
|
||||
}
|
||||
|
||||
if filter != "" {
|
||||
tracks, err := provider.CustomSearch(query, map[string]interface{}{
|
||||
"filter": filter,
|
||||
"limit": 10,
|
||||
})
|
||||
if err == nil && len(tracks) > 0 {
|
||||
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return provider.SearchTracks(query, 10)
|
||||
}
|
||||
|
||||
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(collectionAlbumName(track))
|
||||
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
|
||||
|
||||
score := 0
|
||||
if isCollectionItemType(track, "album") {
|
||||
score += 25
|
||||
}
|
||||
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(collectionArtistName(tracks[i]))
|
||||
score := 0
|
||||
if isCollectionItemType(tracks[i], "artist") {
|
||||
score += 25
|
||||
}
|
||||
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 isCollectionItemType(*track, "album") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
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, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if isCollectionItemType(*track, "artist") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
if url := normalizeShareURL(track.ArtistURL); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func collectionAlbumName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "album") {
|
||||
return track.Name
|
||||
}
|
||||
return track.AlbumName
|
||||
}
|
||||
|
||||
func collectionArtistName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "artist") {
|
||||
return track.Name
|
||||
}
|
||||
return track.Artists
|
||||
}
|
||||
|
||||
func collectionID(track ExtTrackMetadata, itemType string) string {
|
||||
if isCollectionItemType(track, itemType) {
|
||||
return track.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
"album": "https://music.apple.com/us/album/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "1440783617",
|
||||
Name: "Nevermind",
|
||||
Artists: "Nirvana",
|
||||
ItemType: "album",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected album collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
|
||||
t.Fatalf("album share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
"artist": "https://music.youtube.com/browse/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "UCrPe3hLA51968GwxHSZ1llw",
|
||||
Name: "Nirvana",
|
||||
ItemType: "artist",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestArtistTrack(tracks, "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected artist collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
|
||||
t.Fatalf("artist share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
|
||||
apple := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "apple",
|
||||
SourceDir: "/extensions/apple",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
|
||||
},
|
||||
}
|
||||
qobuz := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "qobuz",
|
||||
SourceDir: "/extensions/qobuz",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
|
||||
},
|
||||
}
|
||||
|
||||
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
|
||||
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
|
||||
if first != second {
|
||||
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
|
||||
cacheable := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "album not found"},
|
||||
{ExtensionID: "tidal", Error: "no results"},
|
||||
}
|
||||
if !crossExtensionShareResultsCacheable(cacheable) {
|
||||
t.Fatal("expected found and deterministic not-found results to be cacheable")
|
||||
}
|
||||
|
||||
transient := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "request failed: timeout"},
|
||||
}
|
||||
if crossExtensionShareResultsCacheable(transient) {
|
||||
t.Fatal("expected transient extension errors to skip cache")
|
||||
}
|
||||
}
|
||||
+104
-38
@@ -379,6 +379,7 @@ type reEnrichRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
MaxQuality bool `json:"max_quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
@@ -414,6 +415,21 @@ func (r *reEnrichRequest) shouldUpdateField(field string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
|
||||
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
|
||||
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
|
||||
// callers that do not send lyrics_mode are unaffected.
|
||||
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
|
||||
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
|
||||
}
|
||||
|
||||
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
|
||||
// next to the audio file. Only 'external' and 'both' request a sidecar.
|
||||
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
|
||||
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
|
||||
return mode == "external" || mode == "both"
|
||||
}
|
||||
|
||||
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
if req == nil {
|
||||
return
|
||||
@@ -578,7 +594,7 @@ func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[str
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
if lyricsLRC != "" {
|
||||
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
|
||||
metadata["LYRICS"] = lyricsLRC
|
||||
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
||||
}
|
||||
@@ -594,12 +610,24 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
|
||||
downloadReq := reEnrichDownloadRequest(req)
|
||||
currentISRC := strings.TrimSpace(req.ISRC)
|
||||
currentAlbum := strings.TrimSpace(req.AlbumName)
|
||||
effectiveTrackName := req.TrackName
|
||||
if isPlaceholderReEnrichValue(effectiveTrackName) {
|
||||
effectiveTrackName = ""
|
||||
}
|
||||
effectiveArtistName := req.ArtistName
|
||||
if isPlaceholderReEnrichValue(effectiveArtistName) {
|
||||
effectiveArtistName = ""
|
||||
}
|
||||
var best *ExtTrackMetadata
|
||||
bestScore := -1 << 30
|
||||
|
||||
for i := range tracks {
|
||||
track := &tracks[i]
|
||||
score := 0
|
||||
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
|
||||
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
|
||||
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
|
||||
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
|
||||
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
@@ -607,22 +635,39 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
|
||||
|
||||
if !exactISRCMatch {
|
||||
if effectiveTrackName != "" && !titleMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveArtistName != "" && !artistMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if verified {
|
||||
score += 2000
|
||||
}
|
||||
|
||||
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
|
||||
if exactISRCMatch {
|
||||
score += 10000
|
||||
}
|
||||
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
|
||||
if titleMatches {
|
||||
score += 400
|
||||
}
|
||||
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
|
||||
if artistMatches {
|
||||
score += 320
|
||||
}
|
||||
if currentAlbum != "" && track.AlbumName != "" {
|
||||
switch {
|
||||
case titlesMatch(currentAlbum, track.AlbumName):
|
||||
case albumMatches:
|
||||
score += 120
|
||||
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
||||
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
||||
@@ -1911,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,
|
||||
@@ -2329,37 +2379,7 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
}
|
||||
|
||||
func errorResponse(msg string) (string, error) {
|
||||
errorType := "unknown"
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
errorType = "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
errorType = "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
errorType = "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
strings.Contains(lowerMsg, "all services failed") {
|
||||
errorType = "not_found"
|
||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||
strings.Contains(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
errorType = "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "network") ||
|
||||
strings.Contains(lowerMsg, "connection") ||
|
||||
strings.Contains(lowerMsg, "timeout") ||
|
||||
strings.Contains(lowerMsg, "dial") {
|
||||
errorType = "network"
|
||||
}
|
||||
errorType := classifyDownloadErrorType(msg)
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: false,
|
||||
@@ -2370,6 +2390,41 @@ func errorResponse(msg string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func classifyDownloadErrorType(msg string) string {
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
return "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
return "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||
strings.Contains(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
return "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
return "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
strings.Contains(lowerMsg, "all services failed") {
|
||||
return "not_found"
|
||||
} else if strings.Contains(lowerMsg, "network") ||
|
||||
strings.Contains(lowerMsg, "connection") ||
|
||||
strings.Contains(lowerMsg, "timeout") ||
|
||||
strings.Contains(lowerMsg, "dial") {
|
||||
return "network"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
@@ -2716,7 +2771,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
metadata.ISRC = req.ISRC
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
metadata.Lyrics = lyricsLRC
|
||||
if req.lyricsEmbedEnabled() {
|
||||
metadata.Lyrics = lyricsLRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
metadata.Genre = req.Genre
|
||||
@@ -2751,6 +2808,11 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
"method": "native",
|
||||
"success": true,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"lyrics": lyricsLRC,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
return string(jsonBytes), nil
|
||||
@@ -2766,6 +2828,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
"lyrics": lyricsLRC,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"metadata": ffmpegMetadata,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
|
||||
@@ -11,6 +11,26 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
|
||||
if got != "rate_limit" {
|
||||
t.Fatalf("expected rate_limit, got %q", got)
|
||||
}
|
||||
|
||||
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
|
||||
if err != nil {
|
||||
t.Fatalf("errorResponse returned error: %v", err)
|
||||
}
|
||||
|
||||
var response DownloadResponse
|
||||
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
||||
t.Fatalf("invalid response JSON: %v", err)
|
||||
}
|
||||
if response.ErrorType != "rate_limit" {
|
||||
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
|
||||
@@ -407,6 +407,90 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "wrong-rich-metadata",
|
||||
Name: "Different Song",
|
||||
Artists: "Different Artist",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "2024-03-09",
|
||||
TrackNumber: 4,
|
||||
DiscNumber: 1,
|
||||
ISRC: "WRONG1234567",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
if best := selectBestReEnrichTrack(req, tracks); best != nil {
|
||||
t.Fatalf("selected track = %q, want no match", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
ISRC: "USRC17607839",
|
||||
DurationMs: 999999000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "same-isrc",
|
||||
Name: "Different Song",
|
||||
Artists: "Different Artist",
|
||||
DurationMS: 180000,
|
||||
ISRC: "USRC17607839",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected exact ISRC candidate to be selected")
|
||||
}
|
||||
if best.ID != "same-isrc" {
|
||||
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Unknown Title",
|
||||
ArtistName: "Unknown Artist",
|
||||
AlbumName: "Harry Styles",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "album-match",
|
||||
Name: "Sign of the Times",
|
||||
Artists: "Harry Styles",
|
||||
AlbumName: "Harry Styles",
|
||||
DurationMS: 180000,
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
|
||||
}
|
||||
if best.ID != "album-match" {
|
||||
t.Fatalf("selected track = %q, want album-match", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song",
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
extensionHealthDefaultTimeout = 4 * time.Second
|
||||
extensionHealthMaxBodyBytes = 64 * 1024
|
||||
extensionHealthDefaultCache = 60 * time.Second
|
||||
)
|
||||
|
||||
type ExtensionHealthResult struct {
|
||||
@@ -38,6 +40,16 @@ type ExtensionHealthCheckResult struct {
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
type cachedExtensionHealthResult struct {
|
||||
result ExtensionHealthResult
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
extensionHealthCacheMu sync.Mutex
|
||||
extensionHealthCache = map[string]cachedExtensionHealthResult{}
|
||||
)
|
||||
|
||||
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
@@ -53,6 +65,38 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
|
||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||
return CheckExtensionHealth(ext)
|
||||
}
|
||||
|
||||
cacheKey := strings.TrimSpace(ext.ID)
|
||||
if cacheKey == "" {
|
||||
return CheckExtensionHealth(ext)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
extensionHealthCacheMu.Lock()
|
||||
cached, ok := extensionHealthCache[cacheKey]
|
||||
if ok && now.Before(cached.expiresAt) {
|
||||
extensionHealthCacheMu.Unlock()
|
||||
return cached.result
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
||||
result: result,
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result := ExtensionHealthResult{
|
||||
@@ -98,6 +142,20 @@ func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
|
||||
ttl := extensionHealthDefaultCache
|
||||
for _, check := range checks {
|
||||
if check.CacheTTLSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
||||
if checkTTL < ttl {
|
||||
ttl = checkTTL
|
||||
}
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
|
||||
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
||||
if method == "" {
|
||||
|
||||
@@ -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"`
|
||||
@@ -377,6 +382,64 @@ func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
|
||||
return availability != nil && availability.SkipFallback
|
||||
}
|
||||
|
||||
func fallbackRuntimeHealthStatus(ext *loadedExtension) string {
|
||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status))
|
||||
switch status {
|
||||
case "online", "degraded", "offline":
|
||||
return status
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string {
|
||||
if len(priority) == 0 || extManager == nil {
|
||||
return priority
|
||||
}
|
||||
|
||||
online := make([]string, 0, len(priority))
|
||||
degraded := make([]string, 0, len(priority))
|
||||
unknown := make([]string, 0, len(priority))
|
||||
|
||||
for _, rawProviderID := range priority {
|
||||
providerID := strings.TrimSpace(rawProviderID)
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) {
|
||||
unknown = append(unknown, providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
ext, err := extManager.GetExtension(providerID)
|
||||
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
|
||||
unknown = append(unknown, providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
switch fallbackRuntimeHealthStatus(ext) {
|
||||
case "online":
|
||||
online = append(online, providerID)
|
||||
case "degraded":
|
||||
degraded = append(degraded, providerID)
|
||||
case "offline":
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID)
|
||||
default:
|
||||
unknown = append(unknown, providerID)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(online)+len(degraded)+len(unknown))
|
||||
result = append(result, online...)
|
||||
result = append(result, degraded...)
|
||||
result = append(result, unknown...)
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
|
||||
if availability != nil {
|
||||
if reason := strings.TrimSpace(availability.Reason); reason != "" {
|
||||
@@ -391,10 +454,14 @@ func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err
|
||||
|
||||
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
|
||||
reason := resolveExtensionAvailabilityReason(availability, err)
|
||||
errorType := classifyDownloadErrorType(reason)
|
||||
if errorType == "unknown" {
|
||||
errorType = "extension_error"
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
|
||||
ErrorType: "extension_error",
|
||||
ErrorType: errorType,
|
||||
Service: providerID,
|
||||
}
|
||||
}
|
||||
@@ -680,6 +747,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"),
|
||||
@@ -1786,7 +1858,9 @@ func isRetiredBuiltInDownloadProvider(providerID string) bool {
|
||||
}
|
||||
switch normalized {
|
||||
case "deezer", "qobuz", "tidal":
|
||||
return true
|
||||
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
|
||||
return manifest.IsDownloadProvider()
|
||||
})
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1799,12 +1873,36 @@ func isRetiredBuiltInMetadataProvider(providerID string) bool {
|
||||
}
|
||||
switch normalized {
|
||||
case "deezer", "spotify", "qobuz", "tidal":
|
||||
return true
|
||||
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
|
||||
return manifest.IsMetadataProvider()
|
||||
})
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool {
|
||||
if providerID == "" || matches == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
|
||||
for id, ext := range manager.extensions {
|
||||
if !strings.EqualFold(strings.TrimSpace(id), providerID) {
|
||||
continue
|
||||
}
|
||||
if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil {
|
||||
return false
|
||||
}
|
||||
return matches(ext.Manifest)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func SetExtensionFallbackProviderIDs(providerIDs []string) {
|
||||
extensionFallbackProviderIDsMu.Lock()
|
||||
defer extensionFallbackProviderIDsMu.Unlock()
|
||||
@@ -2374,6 +2472,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source)
|
||||
|
||||
for _, providerID := range priority {
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
@@ -2519,10 +2619,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
errorType := classifyDownloadErrorType(lastErr.Error())
|
||||
if errorType == "unknown" {
|
||||
errorType = "not_found"
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||
ErrorType: "not_found",
|
||||
ErrorType: errorType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2557,9 +2661,10 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
if filename == "" {
|
||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
ext := strings.TrimSpace(req.OutputExt)
|
||||
if ext == "" {
|
||||
@@ -2615,9 +2720,10 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
if filename == "" {
|
||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
outputExt := strings.TrimSpace(req.OutputExt)
|
||||
if outputExt == "" {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -92,6 +93,125 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
|
||||
original := GetProviderPriority()
|
||||
defer SetProviderPriority(original)
|
||||
|
||||
manager := getExtensionManager()
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
ext.ID = "deezer"
|
||||
ext.Manifest.Name = "deezer"
|
||||
|
||||
manager.mu.Lock()
|
||||
previous, hadPrevious := manager.extensions[ext.ID]
|
||||
manager.extensions[ext.ID] = ext
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
if hadPrevious {
|
||||
manager.extensions[ext.ID] = previous
|
||||
} else {
|
||||
delete(manager.extensions, ext.ID)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}()
|
||||
|
||||
SetProviderPriority([]string{"deezer", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"deezer", "custom-ext"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
|
||||
manager := getExtensionManager()
|
||||
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
amazon.ID = "amazon"
|
||||
amazon.Manifest.Name = "amazon"
|
||||
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||
ID: "main",
|
||||
URL: "://bad",
|
||||
Required: true,
|
||||
}}
|
||||
|
||||
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
plain.ID = "plain"
|
||||
plain.Manifest.Name = "plain"
|
||||
|
||||
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
deezer.ID = "deezer"
|
||||
deezer.Manifest.Name = "deezer"
|
||||
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||
ID: "main",
|
||||
URL: "https://example.test/health",
|
||||
}}
|
||||
|
||||
manager.mu.Lock()
|
||||
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
|
||||
previousPlain, hadPlain := manager.extensions[plain.ID]
|
||||
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
|
||||
manager.extensions[amazon.ID] = amazon
|
||||
manager.extensions[plain.ID] = plain
|
||||
manager.extensions[deezer.ID] = deezer
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
if hadAmazon {
|
||||
manager.extensions[amazon.ID] = previousAmazon
|
||||
} else {
|
||||
delete(manager.extensions, amazon.ID)
|
||||
}
|
||||
if hadPlain {
|
||||
manager.extensions[plain.ID] = previousPlain
|
||||
} else {
|
||||
delete(manager.extensions, plain.ID)
|
||||
}
|
||||
if hadDeezer {
|
||||
manager.extensions[deezer.ID] = previousDeezer
|
||||
} else {
|
||||
delete(manager.extensions, deezer.ID)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
delete(extensionHealthCache, deezer.ID)
|
||||
extensionHealthCacheMu.Unlock()
|
||||
}()
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
|
||||
result: ExtensionHealthResult{
|
||||
ExtensionID: deezer.ID,
|
||||
Status: "online",
|
||||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
expiresAt: time.Now().Add(time.Minute),
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
got := prioritizeFallbackProvidersByHealth(
|
||||
[]string{"amazon", "plain", "deezer"},
|
||||
manager,
|
||||
"",
|
||||
)
|
||||
want := []string{"deezer", "plain"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||
if normalized == nil {
|
||||
@@ -286,6 +406,45 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := buildOutputPath(DownloadRequest{
|
||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||
ArtistName: "Artist",
|
||||
OutputDir: outputDir,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{artist} - {title}",
|
||||
})
|
||||
|
||||
base := filepath.Base(outputPath)
|
||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||
t.Fatalf("output filename still contains illegal characters: %q", base)
|
||||
}
|
||||
if strings.Contains(base, `"`) {
|
||||
t.Fatalf("output filename still contains straight double quote: %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||
ArtistName: "Artist",
|
||||
OutputFD: 123,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{artist} - {title}",
|
||||
}, ext)
|
||||
|
||||
base := filepath.Base(resolved)
|
||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||
t.Fatalf("extension output filename still contains illegal characters: %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldStopProviderFallback(t *testing.T) {
|
||||
if shouldStopProviderFallback(nil) {
|
||||
t.Fatal("nil availability should not stop fallback")
|
||||
|
||||
+11
-11
@@ -10,20 +10,20 @@ require (
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
)
|
||||
|
||||
+22
-22
@@ -1,11 +1,11 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
@@ -18,10 +18,10 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TC
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
@@ -30,22 +30,22 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ=
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a h1:sEcsLeiCTTaHGWn+v81+PLAOzzOA9wmzNRqr1WfCmVY=
|
||||
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+4
-11
@@ -77,6 +77,7 @@ var sharedTransport = &http.Transport{
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
var extensionAPITransport = &http.Transport{
|
||||
@@ -95,6 +96,7 @@ var extensionAPITransport = &http.Transport{
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: false,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
var metadataTransport = &http.Transport{
|
||||
@@ -113,6 +115,7 @@ var metadataTransport = &http.Transport{
|
||||
WriteBufferSize: 32 * 1024,
|
||||
ReadBufferSize: 32 * 1024,
|
||||
DisableCompression: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
var sharedClient = &http.Client{
|
||||
@@ -176,17 +179,7 @@ func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
|
||||
}
|
||||
|
||||
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
|
||||
if insecureTLS {
|
||||
cfg := &tls.Config{InsecureSkipVerify: true}
|
||||
if transport.TLSClientConfig != nil {
|
||||
cfg = transport.TLSClientConfig.Clone()
|
||||
cfg.InsecureSkipVerify = true
|
||||
}
|
||||
transport.TLSClientConfig = cfg
|
||||
return
|
||||
}
|
||||
|
||||
transport.TLSClientConfig = nil
|
||||
transport.TLSClientConfig = newTLSCompatibilityConfig(insecureTLS)
|
||||
}
|
||||
|
||||
type compatibilityTransport struct {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -25,11 +27,34 @@ func TestHTTPUtilityHelpers(t *testing.T) {
|
||||
if GetSharedClient() == nil || GetDownloadClient() == nil {
|
||||
t.Fatal("expected shared clients")
|
||||
}
|
||||
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("expected supplemental TLS root pool")
|
||||
}
|
||||
block, _ := pem.Decode([]byte(isrgRootX2PEM))
|
||||
if block == nil {
|
||||
t.Fatal("failed to decode ISRG Root X2")
|
||||
}
|
||||
rootX2, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse ISRG Root X2: %v", err)
|
||||
}
|
||||
if _, err := rootX2.Verify(x509.VerifyOptions{
|
||||
Roots: supplementalRootCAs(),
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}); err != nil {
|
||||
t.Fatalf("ISRG Root X2 should verify with supplemental roots: %v", err)
|
||||
}
|
||||
SetNetworkCompatibilityOptions(true, true)
|
||||
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
|
||||
t.Fatalf("network opts = %#v", opts)
|
||||
}
|
||||
if !sharedTransport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected insecure TLS config to be applied")
|
||||
}
|
||||
SetNetworkCompatibilityOptions(false, false)
|
||||
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected secure TLS config to be restored")
|
||||
}
|
||||
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
|
||||
t.Fatal("GET should fallback")
|
||||
}
|
||||
|
||||
@@ -42,9 +42,12 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
RootCAs: supplementalRootCAs(),
|
||||
InsecureSkipVerify: opts.InsecureTLS,
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}, utls.HelloChrome_Auto)
|
||||
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
|
||||
+213
-23
@@ -7,7 +7,9 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,6 +17,8 @@ type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
const appleMusicCatalogBaseURL = "https://amp-api.music.apple.com/v1/catalog/us"
|
||||
|
||||
type appleMusicSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
@@ -23,9 +27,33 @@ type appleMusicSearchResult struct {
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
type appleMusicCatalogSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
DurationInMillis int `json:"durationInMillis"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
|
||||
type paxResponse struct {
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"`
|
||||
ELRC string `json:"elrc"`
|
||||
ELRCMultiPerson string `json:"elrcMultiPerson"`
|
||||
Plain string `json:"plain"`
|
||||
TTMLContent string `json:"ttmlContent"`
|
||||
}
|
||||
|
||||
type paxLyrics struct {
|
||||
@@ -44,6 +72,11 @@ type paxLyricDetail struct {
|
||||
EndTime *int `json:"endtime"`
|
||||
}
|
||||
|
||||
var (
|
||||
appleMusicTokenMu sync.Mutex
|
||||
appleMusicCachedToken string
|
||||
)
|
||||
|
||||
func NewAppleMusicClient() *AppleMusicClient {
|
||||
return &AppleMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
||||
@@ -100,36 +133,164 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
|
||||
appleMusicTokenMu.Lock()
|
||||
defer appleMusicTokenMu.Unlock()
|
||||
|
||||
if appleMusicCachedToken != "" {
|
||||
return appleMusicCachedToken, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create apple music page request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch apple music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("apple music page returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read apple music page: %w", err)
|
||||
}
|
||||
|
||||
indexPath := regexp.MustCompile(`/assets/index~[^"' <]+\.js`).FindString(string(body))
|
||||
if indexPath == "" {
|
||||
return "", fmt.Errorf("apple music index script not found")
|
||||
}
|
||||
|
||||
jsReq, err := http.NewRequest("GET", "https://beta.music.apple.com"+indexPath, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create apple music script request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
jsResp, err := c.httpClient.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch apple music script: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
if jsResp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("apple music script returned HTTP %d", jsResp.StatusCode)
|
||||
}
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read apple music script: %w", err)
|
||||
}
|
||||
|
||||
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("apple music token not found")
|
||||
}
|
||||
|
||||
appleMusicCachedToken = token
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func clearAppleMusicToken() {
|
||||
appleMusicTokenMu.Lock()
|
||||
defer appleMusicTokenMu.Unlock()
|
||||
appleMusicCachedToken = ""
|
||||
}
|
||||
|
||||
func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusicSearchResult, error) {
|
||||
params := url.Values{}
|
||||
params.Set("term", query)
|
||||
params.Set("types", "songs")
|
||||
params.Set("limit", "25")
|
||||
params.Set("l", "en-US")
|
||||
params.Set("platform", "web")
|
||||
params.Set("format[resources]", "map")
|
||||
params.Set("include[songs]", "artists")
|
||||
params.Set("extend", "artistUrl")
|
||||
|
||||
searchURL := appleMusicCatalogBaseURL + "/search?" + params.Encode()
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create apple music catalog request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||
req.Header.Set("x-apple-renewal", "true")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apple music catalog search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("apple music catalog search unauthorized")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicCatalogSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode apple music catalog response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || searchResp.Resources == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
results := make([]appleMusicSearchResult, 0, len(searchResp.Results.Songs.Data))
|
||||
for _, item := range searchResp.Results.Songs.Data {
|
||||
detail, ok := searchResp.Resources.Songs[item.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attr := detail.Attributes
|
||||
results = append(results, appleMusicSearchResult{
|
||||
ID: item.ID,
|
||||
SongName: attr.Name,
|
||||
ArtistName: attr.ArtistName,
|
||||
AlbumName: attr.AlbumName,
|
||||
Duration: attr.DurationInMillis,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
token, err := c.getAppleMusicToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
|
||||
clearAppleMusicToken()
|
||||
token, tokenErr := c.getAppleMusicToken()
|
||||
if tokenErr != nil {
|
||||
return "", tokenErr
|
||||
}
|
||||
searchResp, err = c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp []appleMusicSearchResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||
@@ -174,8 +335,33 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
}
|
||||
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
|
||||
var stringPayload string
|
||||
if err := json.Unmarshal([]byte(rawJSON), &stringPayload); err == nil {
|
||||
stringPayload = strings.TrimSpace(stringPayload)
|
||||
if stringPayload != "" {
|
||||
return stringPayload, nil
|
||||
}
|
||||
}
|
||||
|
||||
var paxResp paxResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil &&
|
||||
(paxResp.Content != nil ||
|
||||
strings.TrimSpace(paxResp.ELRCMultiPerson) != "" ||
|
||||
strings.TrimSpace(paxResp.ELRC) != "" ||
|
||||
strings.TrimSpace(paxResp.Plain) != "" ||
|
||||
strings.TrimSpace(paxResp.TTMLContent) != "") {
|
||||
if preserveWordTiming && multiPersonWordByWord && strings.TrimSpace(paxResp.ELRCMultiPerson) != "" {
|
||||
return strings.TrimSpace(paxResp.ELRCMultiPerson), nil
|
||||
}
|
||||
if preserveWordTiming && strings.TrimSpace(paxResp.ELRC) != "" {
|
||||
return strings.TrimSpace(paxResp.ELRC), nil
|
||||
}
|
||||
if strings.TrimSpace(paxResp.Plain) != "" && len(paxResp.Content) == 0 {
|
||||
return strings.TrimSpace(paxResp.Plain), nil
|
||||
}
|
||||
if len(paxResp.Content) == 0 {
|
||||
return "", fmt.Errorf("unsupported apple music lyrics payload")
|
||||
}
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
|
||||
}
|
||||
|
||||
@@ -270,6 +456,10 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
|
||||
if err != nil {
|
||||
trimmedRaw := strings.TrimSpace(rawLyrics)
|
||||
if strings.HasPrefix(trimmedRaw, "{") || strings.HasPrefix(trimmedRaw, "[") {
|
||||
return nil, err
|
||||
}
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
|
||||
@@ -131,14 +131,18 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
clearAppleMusicToken()
|
||||
defer clearAppleMusicToken()
|
||||
|
||||
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
|
||||
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/apple-music/search"):
|
||||
if req.URL.Query().Get("q") == "bad" {
|
||||
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`error`)), Request: req}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":"apple-2","songName":"Other","artistName":"Other","duration":1000},{"id":"apple-1","songName":"Song","artistName":"Artist","albumName":"Album","duration":180000}]`)), Request: req}, nil
|
||||
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
|
||||
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil
|
||||
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil
|
||||
default:
|
||||
@@ -177,6 +181,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
if !strings.Contains(elrc, "<00:") {
|
||||
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
|
||||
}
|
||||
if preferred, err := formatPaxLyricsToLRC(`{"elrcMultiPerson":"[00:01.00]v1:<00:01.00>Hello","content":[{"timestamp":1000,"text":[{"text":"Fallback","part":false}]}]}`, true, true); err != nil || !strings.Contains(preferred, "Hello") {
|
||||
t.Fatalf("preferred apple elrc = %q/%v", preferred, err)
|
||||
}
|
||||
if _, err := apple.SearchSong("", "", 0); err == nil {
|
||||
t.Fatal("expected empty apple search error")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const isrgRootX1PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
const isrgRootX2PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
|
||||
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
|
||||
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
|
||||
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
|
||||
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
|
||||
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
|
||||
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
|
||||
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
|
||||
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
|
||||
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
|
||||
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
|
||||
/q4AaOeMSQ+2b1tbFfLn
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
var (
|
||||
supplementalRootCAsOnce sync.Once
|
||||
supplementalRootCAsPool *x509.CertPool
|
||||
)
|
||||
|
||||
func supplementalRootCAs() *x509.CertPool {
|
||||
supplementalRootCAsOnce.Do(func() {
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
|
||||
for _, pem := range []string{isrgRootX1PEM, isrgRootX2PEM} {
|
||||
pool.AppendCertsFromPEM([]byte(pem))
|
||||
}
|
||||
supplementalRootCAsPool = pool
|
||||
})
|
||||
|
||||
return supplementalRootCAsPool
|
||||
}
|
||||
|
||||
func newTLSCompatibilityConfig(insecureTLS bool) *tls.Config {
|
||||
return &tls.Config{
|
||||
RootCAs: supplementalRootCAs(),
|
||||
InsecureSkipVerify: insecureTLS,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
<string>14.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -346,7 +346,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -472,7 +472,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -523,7 +523,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AppInfo {
|
||||
static const String version = '4.5.5';
|
||||
static const String buildNumber = '132';
|
||||
static const String version = '4.5.6';
|
||||
static const String buildNumber = '133';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
|
||||
@@ -1404,6 +1404,12 @@ abstract class AppLocalizations {
|
||||
/// **'No tracks found'**
|
||||
String get errorNoTracksFound;
|
||||
|
||||
/// Subtitle shown under the empty search result state on the home screen
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Try another keyword'**
|
||||
String get searchEmptyResultSubtitle;
|
||||
|
||||
/// Error title - URL not handled by any extension or service
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -6209,6 +6215,18 @@ abstract class AppLocalizations {
|
||||
/// **'Download completed'**
|
||||
String get queueDownloadCompleted;
|
||||
|
||||
/// Title shown on a failed queue item when the download service rate limits requests
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Service rate limited'**
|
||||
String get queueRateLimitTitle;
|
||||
|
||||
/// Explanation shown on a failed queue item when the download service rate limits requests
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.'**
|
||||
String get queueRateLimitMessage;
|
||||
|
||||
/// Accessibility label for picking an accent color
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -6982,6 +7000,78 @@ 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);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -754,6 +754,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Keine Titel gefunden';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link wurde nicht erkannt';
|
||||
|
||||
@@ -3696,6 +3699,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4222,4 +4232,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +742,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -3667,6 +3670,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4193,4 +4203,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +742,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -3661,6 +3664,13 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4187,6 +4197,48 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
|
||||
@@ -745,6 +745,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -3665,6 +3668,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4191,4 +4201,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +742,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -3662,6 +3665,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4188,4 +4198,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,6 +745,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Coba kata kunci lain';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Tautan tidak dikenali';
|
||||
|
||||
@@ -3653,6 +3656,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Layanan sedang membatasi permintaan';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'Lagu ini mungkin masih tersedia. Tunggu beberapa menit, kurangi unduhan paralel, lalu coba lagi.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4179,4 +4189,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,6 +737,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'トラックがありません';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -3649,6 +3652,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4175,4 +4185,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +724,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => '트랙을 찾을 수 없습니다';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -3642,6 +3645,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4168,4 +4178,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +742,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -3662,6 +3665,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4188,4 +4198,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +742,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -3661,6 +3664,13 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4187,6 +4197,48 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
|
||||
@@ -755,6 +755,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Треки не найдены';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Ссылка не распознана';
|
||||
|
||||
@@ -3721,6 +3724,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4247,4 +4257,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,6 +750,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Parça bulunamadı';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Bağlantı tanınamadı';
|
||||
|
||||
@@ -3688,6 +3691,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4214,4 +4224,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,6 +755,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Треків не знайдено';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Посилання не розпізнано';
|
||||
|
||||
@@ -3721,6 +3724,13 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4247,4 +4257,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +742,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -3661,6 +3664,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -4187,6 +4197,48 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
|
||||
@@ -961,6 +961,10 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"searchEmptyResultSubtitle": "Try another keyword",
|
||||
"@searchEmptyResultSubtitle": {
|
||||
"description": "Subtitle shown under the empty search result state on the home screen"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
@@ -4808,6 +4812,14 @@
|
||||
"@queueDownloadCompleted": {
|
||||
"description": "Accessibility label for completed download state in queue"
|
||||
},
|
||||
"queueRateLimitTitle": "Service rate limited",
|
||||
"@queueRateLimitTitle": {
|
||||
"description": "Title shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"queueRateLimitMessage": "This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.",
|
||||
"@queueRateLimitMessage": {
|
||||
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"appearanceSelectAccentColor": "Select accent color {hex}",
|
||||
"@appearanceSelectAccentColor": {
|
||||
"description": "Accessibility label for picking an accent color",
|
||||
@@ -5486,5 +5498,61 @@
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,6 +905,10 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"searchEmptyResultSubtitle": "Coba kata kunci lain",
|
||||
"@searchEmptyResultSubtitle": {
|
||||
"description": "Subtitle shown under the empty search result state on the home screen"
|
||||
},
|
||||
"errorUrlNotRecognized": "Tautan tidak dikenali",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
@@ -4614,5 +4618,69 @@
|
||||
"downloadFallbackExtensionsSubtitle": "Choose which extensions can be used as fallback",
|
||||
"@downloadFallbackExtensionsSubtitle": {
|
||||
"description": "Subtitle for fallback extensions item"
|
||||
},
|
||||
"queueRateLimitTitle": "Layanan sedang membatasi permintaan",
|
||||
"@queueRateLimitTitle": {
|
||||
"description": "Title shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class DownloadItem {
|
||||
case DownloadErrorType.notFound:
|
||||
return 'Song not found on any service';
|
||||
case DownloadErrorType.rateLimit:
|
||||
return 'Rate limit reached, try again later';
|
||||
return 'Service rate limit reached. Wait before retrying.';
|
||||
case DownloadErrorType.network:
|
||||
return 'Connection failed, check your internet';
|
||||
case DownloadErrorType.permission:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -6551,6 +6609,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
|
||||
final lowerMsg = errorMsg.toLowerCase();
|
||||
if (errorMsg.contains('429') ||
|
||||
lowerMsg.contains('rate limit') ||
|
||||
lowerMsg.contains('too many requests')) {
|
||||
return DownloadErrorType.rateLimit;
|
||||
}
|
||||
if (lowerMsg.contains('not found') ||
|
||||
lowerMsg.contains('not available') ||
|
||||
lowerMsg.contains('no results')) {
|
||||
return DownloadErrorType.notFound;
|
||||
}
|
||||
if (lowerMsg.contains('permission') ||
|
||||
lowerMsg.contains('operation not permitted') ||
|
||||
lowerMsg.contains('access denied')) {
|
||||
return DownloadErrorType.permission;
|
||||
}
|
||||
if (lowerMsg.contains('network') ||
|
||||
lowerMsg.contains('connection') ||
|
||||
lowerMsg.contains('timeout') ||
|
||||
lowerMsg.contains('dial')) {
|
||||
return DownloadErrorType.network;
|
||||
}
|
||||
return DownloadErrorType.unknown;
|
||||
}
|
||||
|
||||
Future<void> _processQueue() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
@@ -8633,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);
|
||||
}
|
||||
@@ -8723,7 +8811,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
errorType = DownloadErrorType.permission;
|
||||
break;
|
||||
default:
|
||||
errorType = DownloadErrorType.unknown;
|
||||
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
||||
}
|
||||
|
||||
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
|
||||
@@ -8777,6 +8865,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
errorMsg.contains('track not found on Deezer')) {
|
||||
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
||||
errorType = DownloadErrorType.notFound;
|
||||
} else {
|
||||
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
|
||||
@@ -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,18 +567,24 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
children: [
|
||||
_buildLoveAllButton(),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: Icon(Icons.download, size: 18),
|
||||
label: Text(
|
||||
context.l10n.downloadAllCount(tracks.length),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
Flexible(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: Icon(Icons.download, size: 18),
|
||||
label: Text(
|
||||
context.l10n.downloadAllCount(
|
||||
tracks.length,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -608,6 +615,23 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
tooltip: context.l10n.openInOtherServices,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
|
||||
),
|
||||
onPressed: () => _showShareSheet(context, tracks, artistName),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -846,6 +870,27 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showShareSheet(
|
||||
BuildContext context,
|
||||
List<Track> tracks,
|
||||
String? artistName,
|
||||
) {
|
||||
final sourceExtensionId = _directMetadataProviderId() ?? '';
|
||||
final resolvedArtists =
|
||||
artistName ??
|
||||
tracks.firstOrNull?.albumArtist ??
|
||||
tracks.firstOrNull?.artistName ??
|
||||
'';
|
||||
|
||||
CrossExtensionShareSheet.show(
|
||||
context,
|
||||
name: widget.albumName,
|
||||
artists: resolvedArtists,
|
||||
type: 'album',
|
||||
sourceExtensionId: sourceExtensionId,
|
||||
);
|
||||
}
|
||||
|
||||
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 = {};
|
||||
@@ -1333,6 +1334,33 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
tooltip: context.l10n.openInOtherServices,
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
|
||||
),
|
||||
onPressed: () => _showShareSheet(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showShareSheet(BuildContext context) {
|
||||
CrossExtensionShareSheet.show(
|
||||
context,
|
||||
name: widget.artistName,
|
||||
artists: '',
|
||||
type: 'artist',
|
||||
sourceExtensionId: _directMetadataProviderId() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+116
-10
@@ -46,6 +46,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final _urlController = TextEditingController();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
String? _lastSearchQuery;
|
||||
String? _activeSearchInput;
|
||||
bool _isResettingSearchSurface = false;
|
||||
late final ProviderSubscription<TrackState> _trackStateSub;
|
||||
late final ProviderSubscription<bool> _extensionInitSub;
|
||||
late final ProviderSubscription<bool> _homeFeedExtSub;
|
||||
@@ -557,9 +559,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
if (text.isEmpty) {
|
||||
_liveSearchDebounce?.cancel();
|
||||
_activeSearchInput = null;
|
||||
_lastSearchQuery = null;
|
||||
if (!_isResettingSearchSurface) {
|
||||
_resetSearchSurface(clearText: false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeSearchInput != null && _activeSearchInput != text) {
|
||||
_activeSearchInput = null;
|
||||
}
|
||||
|
||||
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
||||
|
||||
@@ -638,10 +649,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
final searchKey =
|
||||
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
if (_lastSearchQuery == searchKey) {
|
||||
_activeSearchInput = query;
|
||||
ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty);
|
||||
if (mounted) setState(() {});
|
||||
return;
|
||||
}
|
||||
_lastSearchQuery = searchKey;
|
||||
_activeSearchInput = query;
|
||||
_searchSortOption = _SearchSortOption.defaultOrder;
|
||||
_invalidateSearchSortCaches();
|
||||
ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty);
|
||||
|
||||
final isExtensionEnabled =
|
||||
searchProvider != null &&
|
||||
@@ -686,12 +704,26 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
Future<void> _clearAndRefresh() async {
|
||||
_liveSearchDebounce?.cancel();
|
||||
_pendingLiveSearchQuery = null;
|
||||
_urlController.clear();
|
||||
_searchFocusNode.unfocus();
|
||||
_lastSearchQuery = null;
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
_resetSearchSurface();
|
||||
}
|
||||
|
||||
void _resetSearchSurface({bool clearText = true}) {
|
||||
if (_isResettingSearchSurface) return;
|
||||
_isResettingSearchSurface = true;
|
||||
try {
|
||||
_liveSearchDebounce?.cancel();
|
||||
_pendingLiveSearchQuery = null;
|
||||
_lastSearchQuery = null;
|
||||
_activeSearchInput = null;
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (clearText && _urlController.text.isNotEmpty) {
|
||||
_urlController.clear();
|
||||
}
|
||||
ref.read(trackProvider.notifier).clear();
|
||||
if (mounted) setState(() {});
|
||||
} finally {
|
||||
_isResettingSearchSurface = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchMetadata() async {
|
||||
@@ -1114,6 +1146,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
);
|
||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final searchError = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(
|
||||
settingsProvider.select((s) => s.hasSearchedBefore),
|
||||
);
|
||||
@@ -1153,6 +1186,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final isSearchFocused = _searchFocusNode.hasFocus;
|
||||
final hasShortSearchInput =
|
||||
hasSearchInput && searchText.length < _minLiveSearchChars;
|
||||
final hasSearchError = hasSearchInput && searchError != null;
|
||||
final hasActiveSearchSurface =
|
||||
hasSearchInput &&
|
||||
(_activeSearchInput == searchText ||
|
||||
hasActualResults ||
|
||||
isLoading ||
|
||||
hasSearchError);
|
||||
final showEmptySearchResult =
|
||||
hasActiveSearchSurface &&
|
||||
!hasActualResults &&
|
||||
!isLoading &&
|
||||
searchError == null;
|
||||
final isShowingRecentAccess = ref.watch(
|
||||
trackProvider.select((s) => s.isShowingRecentAccess),
|
||||
);
|
||||
@@ -1166,7 +1211,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
|
||||
final showRecentAccess =
|
||||
recentModeRequested &&
|
||||
(!hasSearchInput || hasShortSearchInput || !hasActualResults) &&
|
||||
(!hasSearchInput ||
|
||||
hasShortSearchInput ||
|
||||
(!hasActualResults &&
|
||||
!hasSearchError &&
|
||||
!hasActiveSearchSurface)) &&
|
||||
!isLoading;
|
||||
final isSearchProviderLoading =
|
||||
!extensionReadiness.isInitialized && extensionReadiness.error == null;
|
||||
@@ -1180,6 +1229,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final showExplore =
|
||||
!hasActualResults &&
|
||||
!isLoading &&
|
||||
!hasActiveSearchSurface &&
|
||||
!showRecentAccess &&
|
||||
!homeFeedDisabled &&
|
||||
(hasHomeFeedExtension || hasExploreContent) &&
|
||||
@@ -1299,7 +1349,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
|
||||
if (hasActualResults && !showRecentAccess)
|
||||
if (hasActiveSearchSurface &&
|
||||
!showRecentAccess &&
|
||||
!showEmptySearchResult)
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final currentSearchProvider = ref.watch(
|
||||
@@ -1466,7 +1518,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
|
||||
(searchPlaylists != null && searchPlaylists.isNotEmpty) ||
|
||||
isLoading ||
|
||||
error != null;
|
||||
error != null ||
|
||||
hasActiveSearchSurface;
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: _buildSearchResults(
|
||||
@@ -1478,6 +1531,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
error: error,
|
||||
colorScheme: colorScheme,
|
||||
hasResults: hasResults,
|
||||
showEmptySearchResult: showEmptySearchResult,
|
||||
searchExtensionId: searchExtensionId,
|
||||
showLocalLibraryIndicator: showLocalLibraryIndicator,
|
||||
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
|
||||
@@ -2611,6 +2665,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptySearchResultWidget(ColorScheme colorScheme) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 340),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 86,
|
||||
height: 86,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.manage_search,
|
||||
size: 46,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.errorNoTracksFound,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.searchEmptyResultSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _sortOptionLabel(_SearchSortOption option) {
|
||||
switch (option) {
|
||||
case _SearchSortOption.defaultOrder:
|
||||
@@ -2888,6 +2983,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
required String? error,
|
||||
required ColorScheme colorScheme,
|
||||
required bool hasResults,
|
||||
required bool showEmptySearchResult,
|
||||
required String? searchExtensionId,
|
||||
required bool showLocalLibraryIndicator,
|
||||
required Map<String, (double, double)> thumbnailSizesByExtensionId,
|
||||
@@ -2934,6 +3030,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
),
|
||||
if (showEmptySearchResult && !hasActualData)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 96),
|
||||
child: _buildEmptySearchResultWidget(colorScheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
bool sortButtonShown = false;
|
||||
|
||||
@@ -358,13 +358,10 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
WidgetRef ref,
|
||||
String playlistId,
|
||||
) async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.image,
|
||||
allowMultiple: false,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final picked = await FilePicker.pickFile(type: FileType.image);
|
||||
if (picked == null) return;
|
||||
|
||||
final path = result.files.first.path;
|
||||
final path = picked.path;
|
||||
if (path == null || path.isEmpty) return;
|
||||
|
||||
await ref
|
||||
|
||||
@@ -568,13 +568,10 @@ class _LibraryTracksFolderScreenState
|
||||
final playlistId = widget.playlistId;
|
||||
if (playlistId == null) return;
|
||||
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.image,
|
||||
allowMultiple: false,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final picked = await FilePicker.pickFile(type: FileType.image);
|
||||
if (picked == null) return;
|
||||
|
||||
final path = result.files.first.path;
|
||||
final path = picked.path;
|
||||
if (path == null || path.isEmpty) return;
|
||||
|
||||
await ref
|
||||
|
||||
@@ -863,6 +863,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
await _safeDeleteFile(tempPath!);
|
||||
return false;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
|
||||
}
|
||||
|
||||
if (_hasValue(downloadedCoverPath)) {
|
||||
@@ -875,6 +876,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
await _safeDeleteFile(tempPath!);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
@@ -883,12 +893,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
List<String>? updateFields,
|
||||
}) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final artistTagMode = settings.artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
'file_path': item.filePath,
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'lyrics_mode': settings.lyricsMode,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
@@ -911,6 +923,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
final method = result['method'] as String?;
|
||||
if (method == 'native') {
|
||||
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (method == 'ffmpeg') {
|
||||
|
||||
+168
-18
@@ -3018,24 +3018,77 @@ 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,
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final title = Text(
|
||||
context.l10n.queueDownloadingCount(queueCount),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
final actions = Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
alignment: WrapAlignment.end,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_buildPauseResumeButton(context, ref, colorScheme),
|
||||
_buildClearAllButton(context, ref, colorScheme),
|
||||
],
|
||||
);
|
||||
|
||||
if (constraints.maxWidth < 420) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
title,
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: actions,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: title),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: actions),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
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 +4060,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,
|
||||
@@ -4388,6 +4459,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
await _safeDeleteTempFile(tempPath!);
|
||||
return false;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
|
||||
}
|
||||
|
||||
if (_hasTextValue(downloadedCoverPath)) {
|
||||
@@ -4400,6 +4472,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
await _safeDeleteTempFile(tempPath!);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
@@ -4408,12 +4489,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
List<String>? updateFields,
|
||||
}) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final artistTagMode = settings.artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
'file_path': item.filePath,
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'lyrics_mode': settings.lyricsMode,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
@@ -4436,6 +4519,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
final method = result['method'] as String?;
|
||||
if (method == 'native') {
|
||||
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (method == 'ffmpeg') {
|
||||
@@ -5663,12 +5751,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
],
|
||||
if (item.status == DownloadStatus.failed) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.errorMessage,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(color: colorScheme.error),
|
||||
_buildDownloadFailureMessage(
|
||||
context,
|
||||
item,
|
||||
colorScheme,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -5685,6 +5771,70 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadFailureMessage(
|
||||
BuildContext context,
|
||||
DownloadItem item,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
if (item.errorType != DownloadErrorType.rateLimit) {
|
||||
return Text(
|
||||
item.errorMessage,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: colorScheme.error),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colorScheme.tertiary.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.hourglass_top_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.queueRateLimitTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
context.l10n.queueRateLimitMessage,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
|
||||
final coverSize = _queueCoverSize();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -286,10 +286,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
}
|
||||
|
||||
Future<void> _installExtension() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.any,
|
||||
allowMultiple: true,
|
||||
);
|
||||
final result = await FilePicker.pickFiles(type: FileType.any);
|
||||
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final selectedPaths = result.files
|
||||
|
||||
@@ -91,9 +91,8 @@ class _LyricsProviderPriorityPageState
|
||||
onToggle: () => _disableProvider(id),
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = _enabledProviders.removeAt(oldIndex);
|
||||
_enabledProviders.insert(newIndex, item);
|
||||
});
|
||||
|
||||
@@ -73,11 +73,8 @@ class _MetadataProviderPriorityPageState
|
||||
.firstOrNull,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
|
||||
@@ -145,11 +145,8 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
.firstOrNull,
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _providers.removeAt(oldIndex);
|
||||
_providers.insert(newIndex, item);
|
||||
_hasChanges = true;
|
||||
|
||||
@@ -201,30 +201,30 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
|
||||
Future<void> _pickCoverImage() async {
|
||||
try {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.image,
|
||||
allowMultiple: false,
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final picked = await FilePicker.pickFile(type: FileType.image);
|
||||
if (picked == null) return;
|
||||
|
||||
final picked = result.files.first;
|
||||
final bytes = picked.bytes;
|
||||
final sourcePath = picked.path;
|
||||
Uint8List? bytes;
|
||||
final needsByteFallback =
|
||||
!_hasValue(sourcePath) && !_hasValue(picked.extension);
|
||||
if (needsByteFallback) {
|
||||
bytes = await picked.readAsBytes();
|
||||
}
|
||||
final extension = _resolveImageExtension(picked.extension, bytes);
|
||||
|
||||
final tempDir = await Directory.systemTemp.createTemp('edit_cover_');
|
||||
final tempPath =
|
||||
'${tempDir.path}${Platform.pathSeparator}cover.$extension';
|
||||
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
await File(tempPath).writeAsBytes(bytes, flush: true);
|
||||
} else if (sourcePath != null && sourcePath.isNotEmpty) {
|
||||
if (sourcePath != null && sourcePath.isNotEmpty) {
|
||||
final sourceFile = File(sourcePath);
|
||||
if (!await sourceFile.exists()) {
|
||||
throw Exception('Selected image is not accessible');
|
||||
}
|
||||
await sourceFile.copy(tempPath);
|
||||
} else if (bytes != null && bytes.isNotEmpty) {
|
||||
await File(tempPath).writeAsBytes(bytes, flush: true);
|
||||
} else {
|
||||
throw Exception('Unable to read selected image');
|
||||
}
|
||||
@@ -350,6 +350,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
});
|
||||
}
|
||||
|
||||
void _selectNoFields() {
|
||||
setState(_autoFillFields.clear);
|
||||
}
|
||||
|
||||
String _normalizeMetadataText(String value) {
|
||||
final collapsed = value
|
||||
.toLowerCase()
|
||||
@@ -599,6 +603,57 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return score;
|
||||
}
|
||||
|
||||
bool _metadataTextMatches(String current, String candidate) {
|
||||
if (current.isEmpty || candidate.isEmpty) return false;
|
||||
return current == candidate ||
|
||||
candidate.contains(current) ||
|
||||
current.contains(candidate);
|
||||
}
|
||||
|
||||
bool _metadataMatchIsConfident(
|
||||
Map<String, dynamic> track, {
|
||||
required String currentTitle,
|
||||
required String currentArtist,
|
||||
required String currentAlbum,
|
||||
required String currentIsrc,
|
||||
}) {
|
||||
final candidateIsrc = (track['isrc']?.toString() ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final candidateTitle = _normalizeMetadataText(
|
||||
(track['name'] ?? track['title'] ?? '').toString(),
|
||||
);
|
||||
final candidateArtist = _normalizeMetadataText(
|
||||
(track['artists'] ?? track['artist'] ?? '').toString(),
|
||||
);
|
||||
final candidateAlbum = _normalizeMetadataText(
|
||||
(track['album_name'] ?? track['album'] ?? '').toString(),
|
||||
);
|
||||
|
||||
final titleMatches = _metadataTextMatches(currentTitle, candidateTitle);
|
||||
final artistMatches = _metadataTextMatches(currentArtist, candidateArtist);
|
||||
final albumMatches = _metadataTextMatches(currentAlbum, candidateAlbum);
|
||||
|
||||
if (currentTitle.isNotEmpty && currentArtist.isNotEmpty) {
|
||||
return titleMatches && artistMatches;
|
||||
}
|
||||
if (currentTitle.isNotEmpty && currentAlbum.isNotEmpty) {
|
||||
return titleMatches && albumMatches;
|
||||
}
|
||||
if (currentTitle.isNotEmpty) {
|
||||
return titleMatches;
|
||||
}
|
||||
if (currentAlbum.isNotEmpty) {
|
||||
return albumMatches;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _fetchAndFill() async {
|
||||
if (_autoFillFields.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -683,6 +738,24 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
best = result;
|
||||
}
|
||||
}
|
||||
|
||||
if (best != null &&
|
||||
!_metadataMatchIsConfident(
|
||||
best,
|
||||
currentTitle: normalizedTitle,
|
||||
currentArtist: normalizedArtist,
|
||||
currentAlbum: normalizedAlbum,
|
||||
currentIsrc: currentIsrc,
|
||||
)) {
|
||||
best = null;
|
||||
}
|
||||
|
||||
if (best == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final selectedBest = best;
|
||||
@@ -1405,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2921,7 +2921,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (!_fileExists) return;
|
||||
|
||||
try {
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final artistTagMode = settings.artistTagMode;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
|
||||
);
|
||||
@@ -2931,7 +2932,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
'file_path': cleanFilePath,
|
||||
'cover_url': _coverUrl ?? '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'lyrics_mode': settings.lyricsMode,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': _spotifyId ?? '',
|
||||
'track_name': trackName,
|
||||
@@ -2978,7 +2980,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
if (method == 'native') {
|
||||
// FLAC - handled natively by Go (SAF write-back handled in Kotlin)
|
||||
// FLAC - handled natively by Go (SAF write-back handled in Kotlin).
|
||||
// External .lrc sidecar for filesystem files is written here; SAF
|
||||
// sidecars are created natively in the Kotlin reEnrichFile handler.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: cleanFilePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
await _refreshEmbeddedCoverPreview(force: true);
|
||||
_markMetadataChanged();
|
||||
await _syncDownloadHistoryMetadata();
|
||||
@@ -3072,6 +3080,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(
|
||||
safUri: safUri,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
if (tempPath != null && tempPath.isNotEmpty) {
|
||||
@@ -3081,6 +3093,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: cleanFilePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
await _refreshEmbeddedCoverPreview(force: true);
|
||||
_markMetadataChanged();
|
||||
await _syncDownloadHistoryMetadata();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -817,6 +817,15 @@ class PlatformBridge {
|
||||
return map['success'] == true;
|
||||
}
|
||||
|
||||
static Future<bool> writeSafSidecarLrc(String safUri, String lyrics) async {
|
||||
final result = await _channel.invokeMethod('writeSafSidecarLrc', {
|
||||
'saf_uri': safUri,
|
||||
'lyrics': lyrics,
|
||||
});
|
||||
final map = _decodeRequiredMapResult(result, 'writeSafSidecarLrc');
|
||||
return map['success'] == true;
|
||||
}
|
||||
|
||||
static Future<void> startDownloadService({
|
||||
String trackName = '',
|
||||
String artistName = '',
|
||||
@@ -1300,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;
|
||||
|
||||
@@ -22,6 +22,57 @@ String _sidecarLrcPath(String path) {
|
||||
return '$path.lrc';
|
||||
}
|
||||
|
||||
/// Writes a ".lrc" sidecar next to a re-enriched audio file when the Go backend
|
||||
/// result requests it (`write_external_lrc`), honoring the user's lyrics mode.
|
||||
///
|
||||
/// This handles the filesystem case only. SAF (`content://`) files are written
|
||||
/// centrally by the Kotlin `reEnrichFile` handler, which still holds the
|
||||
/// original document URI, so callers should skip those here (they are detected
|
||||
/// and ignored). Best-effort: returns true only when a sidecar was actually
|
||||
/// written, and never throws.
|
||||
Future<bool> writeReEnrichSidecarLrc({
|
||||
required String audioFilePath,
|
||||
required Map<String, dynamic> reEnrichResult,
|
||||
}) async {
|
||||
if (reEnrichResult['write_external_lrc'] != true) return false;
|
||||
|
||||
// SAF documents are handled natively in Kotlin; nothing to do from Dart.
|
||||
if (isContentUri(audioFilePath)) return false;
|
||||
|
||||
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
|
||||
if (lrc.isEmpty) return false;
|
||||
|
||||
try {
|
||||
final lrcPath = _sidecarLrcPath(audioFilePath);
|
||||
await File(lrcPath).writeAsString(lrc);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a SAF ".lrc" sidecar after a FFmpeg re-enrich write-back succeeds.
|
||||
///
|
||||
/// Native FLAC re-enrich handles SAF sidecars in Kotlin after the direct
|
||||
/// write-back. This helper is for the FFmpeg path, where Dart owns the final
|
||||
/// `writeTempToSaf` success/failure decision.
|
||||
Future<bool> writeReEnrichSafSidecarLrc({
|
||||
required String safUri,
|
||||
required Map<String, dynamic> reEnrichResult,
|
||||
}) async {
|
||||
if (reEnrichResult['write_external_lrc'] != true) return false;
|
||||
if (!isContentUri(safUri)) return false;
|
||||
|
||||
final lrc = (reEnrichResult['lyrics'] as String?)?.trim() ?? '';
|
||||
if (lrc.isEmpty) return false;
|
||||
|
||||
try {
|
||||
return await PlatformBridge.writeSafSidecarLrc(safUri, lrc);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureLyricsMetadataForConversion({
|
||||
required Map<String, String> metadata,
|
||||
required String sourcePath,
|
||||
|
||||
@@ -12,18 +12,45 @@ class AppAnnouncementDialog extends StatelessWidget {
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
void _close(BuildContext context) {
|
||||
onDismiss();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
Future<void> _openCta(BuildContext context) async {
|
||||
final ctaUrl = announcement.ctaUrl;
|
||||
if (ctaUrl == null || ctaUrl.isEmpty) return;
|
||||
if (ctaUrl == null || ctaUrl.isEmpty) {
|
||||
_showCtaOpenFailed(context);
|
||||
return;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(ctaUrl);
|
||||
if (uri == null) return;
|
||||
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
onDismiss();
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
if (uri == null) {
|
||||
_showCtaOpenFailed(context);
|
||||
return;
|
||||
}
|
||||
|
||||
bool launched;
|
||||
try {
|
||||
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (_) {
|
||||
launched = false;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (!launched) {
|
||||
_showCtaOpenFailed(context);
|
||||
return;
|
||||
}
|
||||
|
||||
onDismiss();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
void _showCtaOpenFailed(BuildContext context) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
const SnackBar(content: Text('Unable to open link. Please try again.')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -31,41 +58,60 @@ class AppAnnouncementDialog extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isUrgent = announcement.priority.toLowerCase() == 'high';
|
||||
|
||||
return AlertDialog(
|
||||
icon: Icon(
|
||||
isUrgent ? Icons.priority_high_rounded : Icons.campaign_rounded,
|
||||
color: isUrgent ? colorScheme.error : colorScheme.primary,
|
||||
),
|
||||
title: Text(announcement.title),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
announcement.message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(height: 1.45),
|
||||
// The notice can only be closed through an explicit affordance: never by
|
||||
// tapping the scrim or the system back button (handled by the barrier and
|
||||
// PopScope below). Always keep at least one way out (the X). A
|
||||
// non-dismissible announcement may omit the X only when it carries a CTA,
|
||||
// so the user can never get permanently trapped.
|
||||
final showCloseButton = announcement.dismissible || !announcement.hasCta;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (announcement.hasCta)
|
||||
FilledButton(
|
||||
onPressed: () => _openCta(context),
|
||||
child: Text(announcement.ctaLabel!),
|
||||
),
|
||||
];
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
isUrgent ? Icons.priority_high_rounded : Icons.campaign_rounded,
|
||||
color: isUrgent ? colorScheme.error : colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(announcement.title),
|
||||
),
|
||||
),
|
||||
if (showCloseButton)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _close(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 260),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
announcement.message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(height: 1.45),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: actions.isEmpty ? null : actions,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onDismiss();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
announcement.dismissible
|
||||
? MaterialLocalizations.of(context).closeButtonLabel
|
||||
: 'OK',
|
||||
),
|
||||
),
|
||||
if (announcement.hasCta)
|
||||
FilledButton(
|
||||
onPressed: () => _openCta(context),
|
||||
child: Text(announcement.ctaLabel!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,10 +121,14 @@ Future<void> showAppAnnouncementDialog(
|
||||
required RemoteAnnouncement announcement,
|
||||
required VoidCallback onDismiss,
|
||||
}) {
|
||||
// barrierDismissible is false so a stray tap outside the dialog can no longer
|
||||
// close (and silently mark-as-seen) the notice. Dismissal — and the
|
||||
// mark-as-seen side effect in onDismiss — only happens via the explicit close
|
||||
// button or the CTA, both of which call onDismiss themselves.
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: announcement.dismissible,
|
||||
barrierDismissible: false,
|
||||
builder: (context) =>
|
||||
AppAnnouncementDialog(announcement: announcement, onDismiss: onDismiss),
|
||||
).whenComplete(onDismiss);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/cross_extension_share_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
|
||||
class CrossExtensionShareSheet extends ConsumerStatefulWidget {
|
||||
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
|
||||
ConsumerState<CrossExtensionShareSheet> createState() =>
|
||||
_CrossExtensionShareSheetState();
|
||||
}
|
||||
|
||||
class _CrossExtensionShareSheetState
|
||||
extends ConsumerState<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;
|
||||
});
|
||||
}
|
||||
|
||||
String? _iconPathFor(String extensionId) {
|
||||
if (extensionId.isEmpty) return null;
|
||||
final extensions = ref.read(extensionProvider).extensions;
|
||||
for (final ext in extensions) {
|
||||
if (ext.id == extensionId) {
|
||||
final path = ext.iconPath;
|
||||
return (path != null && path.isNotEmpty) ? path : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
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: [
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
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, 16, 24, 4),
|
||||
child: Text(
|
||||
context.l10n.openInOtherServices,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
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.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(bottom: 16, top: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final result = results[index];
|
||||
return _CrossExtensionShareTile(
|
||||
result: result,
|
||||
iconPath: _iconPathFor(result.extensionId),
|
||||
);
|
||||
},
|
||||
itemCount: results.length,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CrossExtensionShareTile extends StatelessWidget {
|
||||
final CrossExtensionShareResult result;
|
||||
final String? iconPath;
|
||||
|
||||
const _CrossExtensionShareTile({required this.result, this.iconPath});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final url = result.url;
|
||||
final hasUrl = result.found && url != null && url.isNotEmpty;
|
||||
|
||||
final tile = ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _buildIcon(colorScheme),
|
||||
),
|
||||
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: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: hasUrl
|
||||
? IconButton(
|
||||
tooltip: context.l10n.shareSheetCopyLink,
|
||||
icon: const Icon(Icons.copy_rounded, size: 20),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: url));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.shareSheetLinkCopied(result.displayName),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
onTap: hasUrl
|
||||
? () {
|
||||
Navigator.pop(context);
|
||||
ShareIntentService().injectUrl(url);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
if (hasUrl) return tile;
|
||||
return Opacity(opacity: 0.5, child: tile);
|
||||
}
|
||||
|
||||
Widget _buildIcon(ColorScheme colorScheme) {
|
||||
final fallbackIcon = Icon(
|
||||
Icons.extension_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
|
||||
final path = iconPath;
|
||||
if (path == null) return fallbackIcon;
|
||||
|
||||
return Image.file(
|
||||
File(path),
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => fallbackIcon,
|
||||
);
|
||||
}
|
||||
}
|
||||
+145
-145
@@ -5,42 +5,42 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "91.0.0"
|
||||
version: "99.0.0"
|
||||
analysis_server_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analysis_server_plugin
|
||||
sha256: "26844e7f977087567135d62532b67d5639fe206c5194c3f410ba75e1a04a2747"
|
||||
sha256: "3960b28ee740004df39f85d5ebfc91785f7a90e51fd7c9a185e86a36b2f581b4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3"
|
||||
version: "0.3.14"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0
|
||||
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.4.0"
|
||||
version: "12.1.0"
|
||||
analyzer_buffer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_buffer
|
||||
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
|
||||
sha256: bf559bc54530827a92cc4d9ee340fc76b4f17f386218c2b9e7cd33ed468a7e4d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.11"
|
||||
version: "0.3.2"
|
||||
analyzer_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_plugin
|
||||
sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53"
|
||||
sha256: "0057a98d64d7bb872b0c87dff6e73d2c2d80c77156e7a03f127a26f8aa240649"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.10"
|
||||
version: "0.14.8"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -77,10 +77,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
|
||||
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.5"
|
||||
version: "4.0.6"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -117,10 +117,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.5"
|
||||
version: "8.12.6"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -161,14 +161,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
ci:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ci
|
||||
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -197,10 +189,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.2.0"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -265,62 +257,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
custom_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: custom_lint
|
||||
sha256: "751ee9440920f808266c3ec2553420dea56d3c7837dd2d62af76b11be3fcece5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.1"
|
||||
custom_lint_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_core
|
||||
sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.1"
|
||||
custom_lint_visitor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_visitor
|
||||
sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0+8.4.0"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
version: "3.1.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
version: "0.7.13"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
|
||||
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.4.0"
|
||||
version: "13.1.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
version: "8.1.0"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -345,6 +313,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
ffmpeg_kit_flutter_new_full:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -373,10 +349,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
|
||||
sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
version: "12.0.0-beta.5"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -418,34 +394,42 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||
sha256: "3c7aeaded67100c7eecaac46e40097952f1afb8562a3e8b974515ea9cdc5439a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "21.0.0"
|
||||
version: "22.0.0-dev.3"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||
sha256: ad8c60755a783d632ac17e42f03053dabbddfe5766f48b5340b1dac159f94d46
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
version: "8.0.1-dev.1"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||
sha256: "21d45c421e4069d26fccfdd7934a5a5155f2ff352e1fbec0f9efbab3eb243646"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.0"
|
||||
version: "12.0.0-dev.1"
|
||||
flutter_local_notifications_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_web
|
||||
sha256: "8868c009ed125ab2990fe7b5cf84c26b9f25132da40303cfa8a0682efb01922c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0-dev.2"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||
sha256: "2ec0b816f25b5cd59e7a40f88925dbf0fff30944736b71b6920bdd7a421b145e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.1.0-dev.2"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -463,34 +447,34 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02"
|
||||
sha256: be3aa640f053064e2238f8a308baa5be7270645e8b53b08484fd305bd5c1eb5d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "3.3.2-dev.2"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
version: "10.3.1"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
|
||||
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.3.2"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.0.1"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -503,18 +487,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
version: "4.2.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -569,10 +553,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
version: "2.0.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -601,10 +585,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
version: "4.9.1"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -621,22 +605,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
version: "4.12.0"
|
||||
json_serializable:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3
|
||||
sha256: ffcd10cde35a93b2abbbcc26bd9971f4ca93763e8abe78d855e3c4177797e501
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.11.2"
|
||||
version: "6.14.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -705,10 +705,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -725,14 +725,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.6.4"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -753,10 +745,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
version: "9.4.1"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -801,10 +793,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.23"
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -841,10 +833,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||
sha256: ca045d03615023c08ccdb297aad46a9198193666039ddd36d4d85fd0b1864b98
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
version: "12.0.2"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -857,10 +849,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
sha256: "447c18bc3c5fdea5c3039f042b2b365fd51e3634f5f6e269ed22c1f00071addc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
version: "9.4.8"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -949,46 +941,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.1"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a"
|
||||
sha256: da7233961958420e9d80edf4b7a735d5b6b732fe2381d2a12a388562e2042b3f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "3.3.2-dev.2"
|
||||
riverpod_analyzer_utils:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod_analyzer_utils
|
||||
sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0"
|
||||
sha256: "3e275138862ccc22ed61444a1f9a840f753094c367f28f4123f50289cd204d68"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0-dev.8"
|
||||
version: "1.0.0-dev.10"
|
||||
riverpod_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod_annotation
|
||||
sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46
|
||||
sha256: b7fec3dcdef4cc724116b9cad9fd1ca90fb241083cc16e9cf42a85256be7e87e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "4.0.3-dev.2"
|
||||
riverpod_generator:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: riverpod_generator
|
||||
sha256: e43b1537229cc8f487f09b0c20d15dba840acbadcf5fc6dad7ad5e8ab75950dc
|
||||
sha256: "2ba125d0f0ece0b7f2a549613803ea1d4a5e1c8b887588223b19ed8671237504"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0+1"
|
||||
version: "4.0.4-dev.3"
|
||||
riverpod_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: riverpod_lint
|
||||
sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43"
|
||||
sha256: "4ef15442a9f2254ed6ee59a48db84c2c784580ef804061443861b7e0360ec106"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "3.1.4-dev.3"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1001,18 +1001,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
|
||||
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.2"
|
||||
version: "13.1.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
||||
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "7.1.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1110,18 +1110,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
|
||||
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.2"
|
||||
version: "4.2.3"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
|
||||
sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.8"
|
||||
version: "1.3.12"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1166,10 +1166,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "5e8377564d95166761a968ed96104e0569b6b6cc611faac92a36ab8a169112c3"
|
||||
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6+1"
|
||||
version: "2.5.8"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1230,10 +1230,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
version: "3.4.0+1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1246,26 +1246,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.30.0"
|
||||
version: "1.31.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.16"
|
||||
version: "0.6.17"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1294,10 +1294,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1334,10 +1334,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1366,10 +1366,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
version: "15.2.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1414,18 +1414,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
version: "6.3.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
|
||||
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
version: "3.0.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1438,10 +1438,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
version: "7.0.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1459,5 +1459,5 @@ packages:
|
||||
source: hosted
|
||||
version: "2.2.4"
|
||||
sdks:
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
+11
-12
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||
publish_to: "none"
|
||||
version: 4.5.5+132
|
||||
version: 4.5.6+133
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -16,14 +16,14 @@ dependencies:
|
||||
intl: ^0.20.2
|
||||
|
||||
# State Management
|
||||
flutter_riverpod: ^3.1.0
|
||||
flutter_riverpod: ^3.3.1
|
||||
|
||||
# Navigation
|
||||
go_router: ^17.2.3
|
||||
|
||||
# Storage & Persistence
|
||||
shared_preferences: ^2.5.3
|
||||
flutter_secure_storage: 10.0.0
|
||||
flutter_secure_storage: ^10.3.1
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.0
|
||||
sqflite: ^2.4.2+1
|
||||
@@ -43,15 +43,15 @@ dependencies:
|
||||
permission_handler: ^12.0.1
|
||||
|
||||
# File Picker
|
||||
file_picker: ^11.0.2
|
||||
file_picker: ^12.0.0-beta.5
|
||||
|
||||
# JSON Serialization
|
||||
json_annotation: ^4.9.0
|
||||
json_annotation: ^4.11.0
|
||||
|
||||
# Utils
|
||||
url_launcher: ^6.3.1
|
||||
device_info_plus: ^12.3.0
|
||||
share_plus: ^12.0.1
|
||||
device_info_plus: ^13.1.0
|
||||
share_plus: ^13.1.0
|
||||
receive_sharing_intent: ^1.8.1
|
||||
logger: ^2.5.0
|
||||
|
||||
@@ -60,17 +60,16 @@ dependencies:
|
||||
open_filex: ^4.7.0
|
||||
|
||||
# Notifications
|
||||
flutter_local_notifications: 21.0.0
|
||||
flutter_local_notifications: ^22.0.0-dev.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
build_runner: ^2.15.0
|
||||
custom_lint: ^0.8.1
|
||||
riverpod_generator: ^4.0.0
|
||||
riverpod_lint: ^3.1.0
|
||||
json_serializable: ^6.11.2
|
||||
riverpod_generator: ^4.0.4-dev.3
|
||||
riverpod_lint: ^3.1.4-dev.3
|
||||
json_serializable: ^6.14.0
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
|
||||
flutter_launcher_icons:
|
||||
|
||||
@@ -142,7 +142,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
base.copyWith(errorType: DownloadErrorType.rateLimit).errorMessage,
|
||||
'Rate limit reached, try again later',
|
||||
'Service rate limit reached. Wait before retrying.',
|
||||
);
|
||||
expect(
|
||||
base.copyWith(errorType: DownloadErrorType.network).errorMessage,
|
||||
|
||||
Reference in New Issue
Block a user