Compare commits

..

52 Commits

Author SHA1 Message Date
zarzet 8615cde898 chore: bump app to v4.2.2 2026-04-06 14:21:54 +07:00
zarzet 207c0653cc refactor: move deezer to extension 2026-04-06 14:15:44 +07:00
zarzet de756e5d86 fix: preserve flat singles output for extension releases 2026-04-06 04:27:37 +07:00
zarzet fd5db3f7b6 fix: align re-enrich matching with autofill metadata 2026-04-06 03:39:35 +07:00
zarzet d087da9409 fix: persist downloaded metadata and refine metadata navigation 2026-04-06 03:20:04 +07:00
zarzet 43469a7ef2 feat: add configurable extension download fallback 2026-04-06 03:00:17 +07:00
zarzet add4af831e fix: preserve composer metadata across qobuz and history 2026-04-06 01:58:36 +07:00
zarzet 4e530ffbc3 chore: bump app version to v4.2.1 2026-04-04 21:48:19 +07:00
zarzet 14f6776fdc fix: remove stale audio service manifest entries causing crashes on some devices 2026-04-04 21:40:46 +07:00
zarzet da1c6e9171 fix: harden gomobile extension bindings and m4a cover retention 2026-04-04 21:30:11 +07:00
zarzet 9c3e934395 fix: preserve local convert format and library entries 2026-04-04 21:29:20 +07:00
zarzet 15d2c3b465 feat: enrich composer and track totals metadata 2026-04-04 18:50:05 +07:00
zarzet 8aaa6d5cbe fix: preserve embedded metadata details 2026-04-04 18:06:52 +07:00
zarzet 9158d0228d ci: pin iOS release builds to macOS 15 and Xcode 26.1.1 2026-04-04 15:53:46 +07:00
zarzet 2bbcda3320 fix: patch device_info_plus iOS build for older Xcode SDKs 2026-04-04 15:49:34 +07:00
zarzet a7622676dd feat: add additional search/metadata API with separate rate limiting 2026-04-04 13:54:55 +07:00
zarzet 5779f910a2 perf: incremental download queue lookup updates, async cover cleanup, and native JSON decoding on iOS
- Embed DownloadQueueLookup into DownloadQueueState; add updatedForIndices() for O(changed) incremental updates during frequent progress ticks instead of full O(n) rebuild
- downloadQueueLookupProvider now reads pre-computed lookup from state directly
- Replace sync file deletion in DownloadedEmbeddedCoverResolver with unawaited async cleanup to avoid blocking the main thread
- Parse JSON payloads on iOS native side (parseJsonPayload) so event sinks and method channel responses return native objects, avoiding redundant Dart-side JSON decode
- Use .cast<String, dynamic>() instead of Map.from() in _decodeMapResult for zero-copy map handling
2026-04-03 23:03:11 +07:00
zarzet 030f44a444 perf: reduce UI jank via memoization, compute isolates, SQL-backed playlist picker, and viewport-aware image caching
- Move explore JSON decode/encode to compute() isolate to avoid blocking main thread
- Memoize search sort results (artists/albums/playlists/tracks) in HomeTab; invalidate on new query
- Extract _DownloadedOrRemoteCover StatefulWidget with proper embedded-cover lifecycle management
- Replace O(playlists x tracks) in-memory playlist picker check with SQL loadPlaylistPickerSummaries query
- Add FutureProvider.family (libraryPlaylistPickerSummariesProvider) invalidated on all playlist mutations
- Memoize _buildQueueHistoryStats, localPathMatchKeys, and localSingleItems in QueueTab
- Add coverCacheWidthForViewport util; apply memCacheWidth/cacheWidth based on real DPR across all album/playlist/track screens
- Convert sync file ops in TrackMetadataScreen to async; use mtime+size as validation token
- Fetch Deezer album nb_tracks in parallel via fetchAlbumTrackCounts
2026-04-03 22:31:04 +07:00
zarzet 1248270fb4 fix: route Qobuz API calls through authenticated gateway to resolve 401 errors 2026-04-03 21:35:47 +07:00
zarzet 413e3b0686 refactor: consolidate FLAC/MP3/Opus metadata embedding into unified _embedMetadataToFile 2026-04-03 03:22:33 +07:00
zarzet ac711efadc feat: add skipLyrics manifest field for extensions to opt out of lyrics fetching 2026-04-03 03:14:51 +07:00
zarzet 59f2fe880a chore: remove redundant comments and update donor list 2026-04-03 02:21:40 +07:00
zarzet 355f2eba2a fix: resolve missing track/disc numbers from search downloads and suppress FFmpeg log noise
- Tidal: use actual API track_number/disc_number when request values are 0
  (fixes search/popular downloads having no track position in metadata)
- Extension enrichment: copy TrackNumber/DiscNumber back from enriched results
- Extension fallback download: add request metadata fallback for non-source
  extensions (Album, AlbumArtist, ReleaseDate, ISRC, TrackNumber, DiscNumber)
- FFmpeg: add -v error -hide_banner to all commands (embed, convert, CUE split)
  to eliminate banner, build config, and full metadata/lyrics dump in logcat
- ebur128: add framelog=quiet to suppress per-frame loudness measurements
  while keeping the summary needed for ReplayGain parsing
- Track metadata screen: separate embedded lyrics check from online fetch,
  show file-only state with manual online fetch button
2026-04-03 00:56:09 +07:00
zarzet f2f45fa31d fix: improve extension runtime safety, HTTP response URL, SongLink parsing, and recommended service for extensions
- Add 'url' field (final URL after redirects) to all extension HTTP responses and fix fetch polyfill to return final URL instead of original request URL
- Fix RunWithTimeout race condition: increase force-timeout from 1s to 60s to prevent concurrent VM access crashes, add nil guards
- Use lockReadyVM() for thread-safe VM access in GetPlaylistWithExtensionJSON and InvokeAction
- Handle mixed JSON types (string, null, array) in SongLink resolve API SongUrls field
- Fix recommended download service not showing for extension-based searches in download picker
2026-04-02 23:16:37 +07:00
zarzet 042937a8ed fix: resolve label and copyright from file metadata on info screen
The info screen was not reading label/copyright from the actual file metadata, so these fields were always empty for local library items and download history items that lacked them in-memory. Now _resolveAudioMetadata() extracts and displays them without requiring a manual save first.
2026-04-02 19:44:37 +07:00
zarzet 674e9af3d0 fix: validate ISRC in track metadata screen to prevent ID leakage
Sanitize the isrc getter to only return valid ISRC codes (12-char format per ISO 3901). Invalid values such as Spotify/Deezer/Tidal IDs that may leak into the ISRC field are now silently discarded, preventing them from being displayed or embedded into file tags.
2026-04-02 15:29:42 +07:00
zarzet 76d50fab3a fix: correct track/disc defaults, forward extension metadata, and fix service ID display
- Default track/disc number to 0 (unknown) instead of 1, letting the
  backend use the service-provided value or skip the field entirely
- Add releaseDate to ExploreItem so explore downloads carry release info
- Pass discNumber and releaseDate from extension album/playlist tracks
- Fix isDeezer detection using service field instead of substring match
- Add _displayServiceTrackId() to properly strip prefixes for all services
2026-04-02 15:13:11 +07:00
zarzet 81e25d7dab chore: bump version to 4.2.0 (build 121) 2026-04-02 03:20:56 +07:00
zarzet 26f26f792a feat: add ReplayGain scanning, APEv2 tag support, and fix metadata bugs
ReplayGain (track + album):
- Scan track loudness via FFmpeg ebur128 filter (-18 LUFS reference)
- Duration-weighted power-mean for album gain computation
- Support for FLAC (native Vorbis), MP3 (ID3v2 TXXX), Opus, M4A
- Album RG auto-finalizes when all album tracks complete
- Retryable gate: blocks finalization while failed/skipped items exist
- SAF support: lossy album RG writes via temp file + writeTempToSaf
- New embedReplayGain setting (off by default) with UI toggle

APEv2 tag support:
- Full APEv2 reader/writer with header+items+footer format
- Merge-based editing with override keys for explicit deletions
- Binary cover art embedding (Cover Art (Front) item)
- Library scanner support for .ape/.wv/.mpc files
- ReplayGain fields in APE read/write/edit pipeline

Bug fixes (26):
- setArtistComments wiping fields on empty string value
- APEv2 rewrite corrupting files with ID3v1 trailer
- APE edit replacing entire tag instead of merging
- ReplayGain lost on manual MP3/Opus/M4A metadata edit
- Editor metadata save losing custom tags (preserveMetadata)
- Album RG accumulator not cleaned on queue mutation
- Album gain using unweighted mean instead of power-mean
- writeAlbumReplayGainTags return value silently ignored
- SAF album RG writing to deleted temp path
- Cancelled tracks polluting album gain computation
- APE ReplayGain not wired end-to-end
- APE field deletion not working in merge
- APE cover edit was a no-op
- Album RG duplicate entries on retry
- APE apeKeysFromFields missing track/disc/lyrics mappings
- Album RG entries purged by removeItem before computation
- FFmpeg converters discarding empty metadata values
- _appendVorbisArtistEntries skipping empty value (null vs empty)
- Album RG write-back fails for SAF lossy files
- Album RG partial finalization on failed tracks
- FLAC ClearEmpty flag destroying tags on partial callers
- clearCompleted not retriggering album RG checks
- ReadFileMetadata MP3/Ogg missing label and copyright
- Cover embed on CUE split destroying split artist tags
- Album RG gain format inconsistent (missing + prefix)
- FLAC reader/editor missing tag aliases (ALBUMARTIST, LABEL, etc.)
- dart:math log shadowed by logger.dart export
2026-04-02 03:15:01 +07:00
zarzet 4dfa76b49e fix: remove deleted local library item from provider state after file deletion
When deleting a non-CUE local library track from the metadata screen,
only the file was removed but the library database entry and provider
state were left untouched, causing the track to persist in the library UI.
Now calls removeItem() on localLibraryProvider after deleteFile().
2026-04-01 21:04:42 +07:00
zarzet f511f30ad0 feat: add resolve API with SongLink fallback, fix multi-artist tags (#288), and cleanup
Resolve API (api.zarz.moe):
- Refactor songlink.go: Spotify URLs use resolve API, non-Spotify uses SongLink API
- Add SongLink fallback when resolve API fails for Spotify (two-layer resilience)
- Remove dead code: page parser, XOR-obfuscated keys, legacy helpers

Multi-artist tag fix (#288):
- Add RewriteSplitArtistTags() in Go to rewrite ARTIST/ALBUMARTIST as split Vorbis comments
- Wire method channel handler in Android (MainActivity.kt) and iOS (AppDelegate.swift)
- Add PlatformBridge.rewriteSplitArtistTags() in Dart
- Call native FLAC rewriter after FFmpeg embed when split_vorbis mode is active
- Extract deezerTrackArtistDisplay() helper to use Contributors in album/playlist tracks

Code cleanup:
- Remove unused imports, dead code, and redundant comments across Go and Dart
- Fix build: remove stale getQobuzDebugKey() reference in deezer_download.go
2026-04-01 02:49:19 +07:00
zarzet a1aa1319ce feat: add separate filename format for singles and EPs (#271)
Add singleFilenameFormat setting so singles/EPs can use a different filename template than albums. The format editor is reused with custom title/description. Dart selects the correct format based on track.isSingle before passing to Go, so no backend changes needed. Also fix isSingle getter to include all EPs regardless of totalTracks. Closes #271
2026-03-31 18:55:48 +07:00
zarzet c936bd7dd0 fix: match system navigation bar color with app theme
Set systemNavigationBarColor to surfaceContainer (matching the in-app
NavigationBar) via AppBarTheme.systemOverlayStyle. Handles light, dark,
AMOLED and dynamic color schemes automatically.

Closes zarzet/SpotiFLAC-Mobile#284
2026-03-31 18:36:28 +07:00
zarzet 3a60ea2f4e feat: add field selection dialog for bulk re-enrich metadata
Add a bottom sheet dialog that lets users choose which metadata field
groups to update during bulk re-enrich (cover, lyrics, album/album
artist, track/disc number, date/ISRC, genre/label/copyright).

Backend (Go):
- Filter FLAC Metadata struct and FFmpeg metadata map by selected
  update_fields so non-selected groups preserve existing file values
- Guard Deezer extended metadata fetch with shouldUpdateField(extra)
- Title/Artist are never overwritten by re-enrich (search keys only)
- enrichedMeta response only includes selected field groups

Frontend (Dart):
- New re_enrich_field_dialog.dart bottom sheet with checkboxes
- FFmpegService embed methods gain preserveMetadata param that uses
  -map_metadata 0 instead of -1 to preserve non-selected tags
- Hide selection overlay/bar before showing dialog, restore on cancel
- Fix setState-after-dispose guard in cancel branches

Cleanup:
- Remove dead code in library_tracks_folder_screen.dart
- Fix use_build_context_synchronously in main_shell.dart
- Suppress false-positive use_null_aware_elements lints
- Update l10n label from 'Title, Artist, Album' to 'Album, Album Artist'
2026-03-31 18:21:45 +07:00
zarzet 7dba938299 fix: prefer local file for cover/lyrics save and update build dependencies
- Cover art: extract from downloaded file first, fall back to URL download
- Lyrics: check embedded lyrics/sidecar LRC before fetching online
- Add audioFilePath param to FetchAndSaveLyrics (Go, Kotlin, Swift, Dart)
- Handle SAF content:// URIs for lyrics extraction in Kotlin bridge
- Update Go 1.25.7 -> 1.25.8, Gradle 9.3.1 -> 9.4.1, Kotlin 2.2.21 -> 2.3.20
- Update NDK r27d -> r28b, Flutter FVM 3.41.4 -> 3.41.5
- Upgrade all Flutter and Go module dependencies to latest
2026-03-31 17:25:30 +07:00
zarzet 93e77aeb84 refactor: remove legacy API clients, Yoinkify fallback, and unused lyrics provider
- Delete dead metadata client and extract shared types to metadata_types.go
- Remove Yoinkify download fallback from Deezer, use MusicDL only
- Clean up retired settings fields and metadataSource
- Remove dead l10n keys for retired provider
- Add migration to strip retired provider from existing users' lyrics config
2026-03-30 23:26:37 +07:00
zarzet dd750b95ca chore: bump version to 4.1.3 (build 120) 2026-03-30 18:25:42 +07:00
zarzet e42e44f28b fix: Samsung SAF library scan, Qobuz album cover, M4A metadata save and log improvements
- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback
  to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg)
- Propagate displayNameHint to all format scanners so fd numbers (214, 207)
  no longer appear as track names when /proc/self/fd/ paths are used
- Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after
  first failure, reducing error log noise and improving scan speed on Samsung
- Fix Qobuz download returning wrong album cover when track exists on
  multiple albums by preferring req.CoverURL over API default
- Fix FFmpeg M4A metadata save failing with 'codec not currently supported
  in container' by forcing mp4 muxer instead of ipod when cover art present
- Clean up FLAC SAF temp file after metadata write-back (was leaking)
- Update LRC lyrics tag to credit Paxsenix API
- Remove log message truncation, defer to UI preview truncation instead
2026-03-30 18:12:20 +07:00
zarzet 67daefdf60 feat: add artist tag mode setting with split Vorbis support and improve library scan progress
- Add artist_tag_mode setting (joined / split_vorbis) for FLAC/Opus multi-artist tags
- Split 'Artist A, Artist B' into separate ARTIST= Vorbis comments when split mode is enabled
- Join repeated ARTIST/ALBUMARTIST Vorbis comments when reading metadata
- Propagate artistTagMode through download pipeline, re-enrich, and metadata editor
- Improve library scan progress: separate polling intervals, finalizing state, indeterminate progress
- Add initial progress snapshot on library scan stream connect
- Use req.ArtistName consistently for Qobuz downloads instead of track.Performer.Name
- Add l10n keys for artist tag mode, library files unit, and scan finalizing status
2026-03-30 12:38:42 +07:00
zarzet fabaf0a3ff feat: add stable cover cache keys, Qobuz album-search fallback, metadata filters and extended sort options
- Introduce coverCacheKey parameter through Go backend and Kotlin bridge for stable SAF cover caching
- Add MetadataFromFilename flag to skip filename-only metadata and retry via temp-file copy
- Add Qobuz album-search fallback between API search and store scraping
- Extract buildReEnrichFFmpegMetadata to skip empty metadata fields
- Add metadata completeness filter (complete, missing year/genre/album artist)
- Add sort modes: artist, album, release date, genre (asc/desc)
- Prune stale library cover cache files after full scan
- Skip empty values and zero track/disc numbers in FFmpeg metadata
- Add new l10n keys for metadata filter and sort options
2026-03-30 11:41:11 +07:00
zarzet fb90c73f42 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-03-29 18:57:13 +07:00
zarzet c6cf65f075 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-03-29 18:49:57 +07:00
zarzet 25de009ebc feat: replace batch operation snackbars with progress dialog
Add reusable BatchProgressDialog widget with circular/linear progress
indicators, cancel support, and track detail display. Uses ValueNotifier
pattern to communicate progress from caller to dialog across navigator
routes.
2026-03-29 18:04:38 +07:00
zarzet 8918d74bb5 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-03-29 17:45:51 +07:00
zarzet f9de8d45d9 fix: add attached_pic disposition to ALAC cover art embedding 2026-03-29 17:41:43 +07:00
zarzet 48eef0853d i18n: extract hardcoded strings into l10n keys
Move hardcoded UI strings across multiple screens and the notification
service into ARB-backed l10n keys so they can be translated via Crowdin.
Adds 62 new keys covering sort labels, dialog copy, metadata error
snackbars, folder-picker errors, home-tab error states, extensions home
feed selector, and all notification titles/bodies. NotificationService
now caches an AppLocalizations instance (injected from MainShell via
didChangeDependencies) and falls back to English literals when no locale
is available.
2026-03-29 17:02:12 +07:00
zarzet fc70a912bf refactor: route spotify URLs through extensions 2026-03-29 16:35:16 +07:00
zarzet cd3e5b4b28 chore: bump version to 4.1.2+119 2026-03-29 15:40:24 +07:00
zarzet 482ca82eb4 feat: improve track matching 2026-03-29 15:34:44 +07:00
zarzet 6d87ae5484 feat: add haptic feedback when swiping library tabs 2026-03-29 01:56:22 +07:00
zarzet bd3e2b999b feat: add play button to playlist/library track tiles
Show a play IconButton (matching local album style) next to the
more-options button when a track has a local file available.
Uses PlaybackController.playTrackList to resolve and open the file.
2026-03-29 01:54:27 +07:00
zarzet 186196e12b fix: use START_NOT_STICKY for DownloadService to prevent auto-restart
Prevents Android from automatically recreating the download service
after it is killed, avoiding duplicate or orphaned download processes.
2026-03-29 01:37:24 +07:00
107 changed files with 1902 additions and 33517 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|---|---|---|---|---|
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | [Monochrome](https://monochrome.tf) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
---
-4
View File
@@ -20,10 +20,6 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
buildFeatures {
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
-14
View File
@@ -86,20 +86,6 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="music.youtube.com" />
</intent-filter>
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
@@ -308,40 +307,8 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun forceFilenameExt(name: String, outputExt: String): String {
val normalizedExt = normalizeExt(outputExt)
if (normalizedExt.isBlank()) return sanitizeFilename(name)
val safeName = sanitizeFilename(name)
val lower = safeName.lowercase(Locale.ROOT)
val knownExts = listOf(".flac", ".m4a", ".mp3", ".opus", ".lrc")
for (knownExt in knownExts) {
if (lower.endsWith(knownExt)) {
return safeName.dropLast(knownExt.length) + normalizedExt
}
}
return safeName + normalizedExt
}
private fun sanitizeFilename(name: String): String {
var sanitized = name
.replace("/", " ")
.replace(Regex("[\\\\:*?\"<>|]"), " ")
.filter { ch ->
val code = ch.code
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
code == 0x7F ||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
}
.trim()
.trim('.', ' ')
sanitized = sanitized
.replace(Regex("\\s+"), " ")
.replace(Regex("_+"), "_")
.trim('_', ' ')
return if (sanitized.isBlank()) "Unknown" else sanitized
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
}
private fun sanitizeRelativeDir(relativeDir: String): String {
@@ -401,43 +368,6 @@ class MainActivity: FlutterFragmentActivity() {
return current
}
private fun createOrReuseDocumentFile(
parent: DocumentFile,
mimeType: String,
fileName: String
): DocumentFile? {
val safeFileName = sanitizeFilename(fileName)
if (safeFileName.isBlank()) return null
synchronized(safDirLock) {
val existing = parent.findFile(safeFileName)
if (existing != null && existing.isFile) {
return existing
}
val created = parent.createFile(mimeType, safeFileName) ?: return null
val createdName = created.name ?: safeFileName
if (createdName == safeFileName) {
return created
}
// SAF can auto-rename to "name (1)" when another writer wins the race
// between findFile() and createFile(). Prefer the exact sibling if it
// appeared, and discard the duplicate document we just created.
val winner = parent.findFile(safeFileName)
if (winner != null && winner.isFile) {
if (winner.uri != created.uri) {
try {
created.delete()
} catch (_: Exception) {}
}
return winner
}
return created
}
}
private fun resetSafScanProgress() {
synchronized(safScanLock) {
safScanProgress = SafScanProgress()
@@ -669,12 +599,12 @@ class MainActivity: FlutterFragmentActivity() {
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
if (provided.isNotBlank()) return sanitizeFilename(provided)
val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "")
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
return forceFilenameExt(baseName, outputExt)
return sanitizeFilename(baseName) + outputExt
}
private fun errorJson(message: String): String {
@@ -988,7 +918,8 @@ class MainActivity: FlutterFragmentActivity() {
val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
var document = createOrReuseDocumentFile(targetDir, mimeType, fileName)
val existingFile = targetDir.findFile(fileName)
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
@@ -1016,21 +947,6 @@ class MainActivity: FlutterFragmentActivity() {
if (!srcFile.exists() || srcFile.length() <= 0) {
throw IllegalStateException("extension output missing or empty: $goFilePath")
}
val actualExt = normalizeExt(srcFile.extension)
if (actualExt.isNotBlank() && actualExt != outputExt) {
val actualFileName = buildSafFileName(req, actualExt)
val actualMimeType = mimeTypeForExt(actualExt)
val replacement = createOrReuseDocumentFile(
targetDir,
actualMimeType,
actualFileName,
)
?: throw IllegalStateException("failed to create SAF output with actual extension")
if (replacement.uri != document.uri) {
document.delete()
document = replacement
}
}
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
@@ -2018,54 +1934,9 @@ class MainActivity: FlutterFragmentActivity() {
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
override fun shouldHandleDeeplinking(): Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleExtensionOAuthIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleExtensionOAuthIntent(intent)
}
/**
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
* and run its token exchange (e.g. completeSpotifyLogin). State must be the extension id.
*/
private fun handleExtensionOAuthIntent(intent: Intent?) {
val uri = intent?.data ?: return
if (!uri.scheme.equals("spotiflac", ignoreCase = true)) {
return
}
val host = (uri.host ?: "").lowercase(Locale.US)
val path = (uri.path ?: "").lowercase(Locale.US)
val isCallback =
host == "callback" ||
host == "spotify-callback" ||
path.contains("callback")
if (!isCallback) {
return
}
val code = uri.getQueryParameter("code")?.trim().orEmpty()
if (code.isEmpty()) {
return
}
val extId = uri.getQueryParameter("state")?.trim().orEmpty()
if (extId.isEmpty()) {
android.util.Log.w("SpotiFLAC", "Extension OAuth redirect missing state (extension id)")
return
}
intent.data = null
scope.launch(Dispatchers.IO) {
try {
Gobackend.setExtensionAuthCodeByID(extId, code)
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
}
}
}
override fun onDestroy() {
@@ -2081,7 +1952,6 @@ class MainActivity: FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Gobackend.setAppVersion(BuildConfig.VERSION_NAME)
// Always-enabled back callback to ensure back presses reach Flutter.
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
@@ -2266,6 +2136,7 @@ class MainActivity: FlutterFragmentActivity() {
result.error("saf_pending", "SAF picker already active", null)
return@launch
}
pendingSafTreeResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
@@ -2273,24 +2144,7 @@ class MainActivity: FlutterFragmentActivity() {
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
)
val resolver = intent.resolveActivity(packageManager)
if (resolver == null) {
result.error("saf_unavailable", "No folder picker available on this device", null)
return@launch
}
pendingSafTreeResult = result
try {
android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver")
safTreeLauncher.launch(intent)
} catch (e: Exception) {
pendingSafTreeResult = null
android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e)
result.error(
"saf_launch_failed",
e.message ?: "Failed to launch folder picker",
null
)
}
safTreeLauncher.launch(intent)
}
"safExists" -> {
val uriStr = call.argument<String>("uri") ?: ""
@@ -2365,8 +2219,7 @@ class MainActivity: FlutterFragmentActivity() {
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName)
val createdNew = existing == null
val doc = createOrReuseDocumentFile(dir, mimeType, fileName)
?: return@withContext null
val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null
if (!writeUriFromPath(doc.uri, srcPath)) {
if (createdNew) {
doc.delete()
@@ -2864,6 +2717,16 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
+1 -1
View File
@@ -1,2 +1,2 @@
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.3.1",
"versionDate": "2026-04-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.3.1/SpotiFLAC-v4.3.1-ios-unsigned.ipa",
"version": "3.9.0",
"versionDate": "2026-03-25",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC 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": 34773644
"size": 34477323
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

-5
View File
@@ -6,7 +6,6 @@ files:
# Short codes for single-variant languages
de: de
es: es
es-ES: es_ES
fr: fr
hi: hi
id: id
@@ -14,11 +13,7 @@ files:
ko: ko
nl: nl
pt: pt
pt-PT: pt_PT
ru: ru
tr: tr
uk: uk
zh: zh
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
-14
View File
@@ -10,7 +10,6 @@ import (
var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct {
ctx context.Context
cancel context.CancelFunc
canceled bool
}
@@ -28,21 +27,8 @@ func initDownloadCancel(itemID string) context.Context {
cancelMu.Lock()
defer cancelMu.Unlock()
if entry, ok := cancelMap[itemID]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
entry.cancel = cancel
if entry.canceled && entry.cancel != nil {
entry.cancel()
}
}
return entry.ctx
}
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
ctx: ctx,
cancel: cancel,
canceled: false,
}
+59 -233
View File
@@ -5,16 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/dop251/goja"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func CheckAvailability(spotifyID, isrc string) (string, error) {
@@ -37,113 +33,6 @@ func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
}
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
type musicBrainzTag struct {
Count int `json:"count"`
Name string `json:"name"`
}
type musicBrainzRecordingResponse struct {
Recordings []struct {
Tags []musicBrainzTag `json:"tags"`
} `json:"recordings"`
}
func formatMusicBrainzGenre(tags []musicBrainzTag) string {
if len(tags) == 0 {
return ""
}
caser := cases.Title(language.English)
seen := make(map[string]struct{}, len(tags))
maxCount := -1
bestTag := ""
for _, tag := range tags {
name := strings.TrimSpace(tag.Name)
if name == "" {
continue
}
key := strings.ToLower(name)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
formatted := caser.String(name)
if tag.Count > maxCount {
maxCount = tag.Count
bestTag = formatted
}
}
return bestTag
}
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
if normalizedISRC == "" {
return "", fmt.Errorf("no ISRC provided")
}
client := NewMetadataHTTPClient(10 * time.Second)
query := fmt.Sprintf("isrc:%s", normalizedISRC)
reqURL := fmt.Sprintf(
"%s/recording?query=%s&fmt=json&inc=tags",
musicBrainzAPIBase,
url.QueryEscape(query),
)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
resp, lastErr = client.Do(req)
if lastErr == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
if attempt < 2 {
time.Sleep(2 * time.Second)
}
}
if lastErr != nil {
return "", lastErr
}
if resp == nil {
return "", fmt.Errorf("MusicBrainz request failed without response")
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
}
defer resp.Body.Close()
var payload musicBrainzRecordingResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", err
}
if len(payload.Recordings) == 0 {
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
}
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
if genre == "" {
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
}
return genre, nil
}
type DownloadRequest struct {
ISRC string `json:"isrc"`
Service string `json:"service"`
@@ -238,12 +127,6 @@ type DownloadResult struct {
Decryption *DownloadDecryptionInfo
}
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
}
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
type reEnrichRequest struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
@@ -796,75 +679,6 @@ func enrichResultQualityFromFile(result *DownloadResult) {
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
}
func applyExtendedMetadataFields(
genre *string,
label *string,
copyright *string,
extMeta *AlbumExtendedMetadata,
) {
if extMeta == nil {
return
}
if genre != nil && *genre == "" && extMeta.Genre != "" {
*genre = extMeta.Genre
}
if label != nil && *label == "" && extMeta.Label != "" {
*label = extMeta.Label
}
if copyright != nil && *copyright == "" && extMeta.Copyright != "" {
*copyright = extMeta.Copyright
}
}
func enrichExtraMetadataByISRC(
logPrefix string,
isrc string,
genre *string,
label *string,
copyright *string,
) {
normalizedISRC := strings.TrimSpace(isrc)
if normalizedISRC == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err)
}
applyExtendedMetadataFields(genre, label, copyright, extMeta)
if genre != nil && *genre == "" {
musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC)
if err != nil {
GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err)
} else if musicBrainzGenre != "" {
*genre = musicBrainzGenre
GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre)
}
}
currentGenre := ""
currentLabel := ""
currentCopyright := ""
if genre != nil {
currentGenre = *genre
}
if label != nil {
currentLabel = *label
}
if copyright != nil {
currentCopyright = *copyright
}
if currentGenre != "" || currentLabel != "" || currentCopyright != "" {
GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright)
}
}
func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil {
return
@@ -874,13 +688,30 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
return
}
enrichExtraMetadataByISRC(
"DownloadWithFallback",
req.ISRC,
&req.Genre,
&req.Label,
&req.Copyright,
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
if err != nil || extMeta == nil {
if err != nil {
GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
return
}
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
func applySongLinkRegionFromRequest(req *DownloadRequest) {
@@ -1485,7 +1316,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
coverPath := strings.TrimSpace(fields["cover_path"])
if isFlac {
@@ -1531,7 +1361,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
DiscNumber: discNum,
TotalDiscs: totalDiscs,
ISRC: fields["isrc"],
Lyrics: fields["lyrics"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
@@ -1598,19 +1427,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
if isM4AFile && hasOnlyM4AReplayGainFields(fields) {
if err := EditM4AReplayGain(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native_m4a_replaygain",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
resp := map[string]any{
"success": true,
"method": "ffmpeg",
@@ -1620,29 +1436,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
allowed := map[string]struct{}{
"replaygain_track_gain": {},
"replaygain_track_peak": {},
"replaygain_album_gain": {},
"replaygain_album_peak": {},
}
hasReplayGain := false
for key, value := range fields {
if strings.TrimSpace(value) == "" {
continue
}
if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok {
hasReplayGain = true
continue
}
return false
}
return hasReplayGain
}
func SetDownloadDirectory(path string) error {
return setDownloadDir(path)
}
@@ -1879,6 +1672,24 @@ func ClearTrackIDCache() {
ClearTrackCache()
}
func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewTidalDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
@@ -2487,6 +2298,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
if req.SearchOnline {
found := false
deezerClient := GetDeezerClient()
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := getExtensionManager()
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
@@ -2515,9 +2327,23 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
}
// Try to enrich extra metadata from ISRC if not already set.
// Try to get extended metadata from Deezer if not already set
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
if !found {
+1 -90
View File
@@ -1,9 +1,6 @@
package gobackend
import (
"context"
"testing"
)
import "testing"
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
original := GetExtensionFallbackProviderIDs()
@@ -164,92 +161,6 @@ func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T
}
}
func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
got := formatMusicBrainzGenre([]musicBrainzTag{
{Name: "art pop", Count: 3},
{Name: "pop", Count: 8},
{Name: "dance pop", Count: 5},
})
if got != "Pop" {
t.Fatalf("genre = %q, want %q", got, "Pop")
}
}
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
}()
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return nil, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
if isrc != "TEST123" {
t.Fatalf("unexpected isrc: %q", isrc)
}
return "Alternative Rock", nil
}
genre := ""
label := ""
copyright := ""
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST123", &genre, &label, &copyright)
if genre != "Alternative Rock" {
t.Fatalf("genre = %q, want fallback genre", genre)
}
if label != "" {
t.Fatalf("label = %q, want empty", label)
}
if copyright != "" {
t.Fatalf("copyright = %q, want empty", copyright)
}
}
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
defer func() {
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
}()
musicBrainzCalled := false
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
return &AlbumExtendedMetadata{
Genre: "Synthpop",
Label: "EMI",
Copyright: "(C) Test",
}, nil
}
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
musicBrainzCalled = true
return "Rock", nil
}
genre := ""
label := ""
copyright := ""
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, &copyright)
if genre != "Synthpop" {
t.Fatalf("genre = %q, want Deezer genre", genre)
}
if label != "EMI" {
t.Fatalf("label = %q, want Deezer label", label)
}
if copyright != "(C) Test" {
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
}
if musicBrainzCalled {
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
}
}
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{
SpotifyID: "spotify-track-id",
+3 -15
View File
@@ -893,6 +893,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
IconPath string `json:"icon_path,omitempty"`
@@ -950,6 +951,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
Name: ext.Manifest.Name,
DisplayName: ext.Manifest.DisplayName,
Version: ext.Manifest.Version,
Author: ext.Manifest.Author,
Description: ext.Manifest.Description,
Homepage: ext.Manifest.Homepage,
IconPath: iconPath,
@@ -1053,29 +1055,15 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
}
defer ext.VMMu.Unlock()
// Merge extension return values onto the top-level JSON object so Flutter can read
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
// Handle promise - return pending status
return { success: true, pending: true, message: 'Action started' };
}
if (result !== null && result !== undefined && typeof result === 'object') {
var isArr = false;
if (typeof Array !== 'undefined' && Array.isArray) {
isArr = Array.isArray(result);
}
if (!isArr) {
var out = { success: true };
for (var k in result) {
out[k] = result[k];
}
return out;
}
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
+5
View File
@@ -105,6 +105,7 @@ type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
@@ -154,6 +155,10 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "version", Message: "version is required"}
}
if strings.TrimSpace(m.Author) == "" {
return &ManifestValidationError{Field: "author", Message: "author is required"}
}
if strings.TrimSpace(m.Description) == "" {
return &ManifestValidationError{Field: "description", Message: "description is required"}
}
+60 -77
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -118,16 +119,9 @@ type ExtDownloadResult struct {
AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"`
Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Composer string `json:"composer,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
}
@@ -622,10 +616,6 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
p.extension.runtime.setActiveDownloadItemID(itemID)
defer p.extension.runtime.clearActiveDownloadItemID()
}
if itemID != "" {
initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
@@ -816,6 +806,9 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string {
}
normalizedBuiltIn := strings.ToLower(providerID)
if normalizedBuiltIn == "deezer" {
continue
}
if isBuiltInDownloadProvider(normalizedBuiltIn) {
providerID = normalizedBuiltIn
}
@@ -902,7 +895,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
sanitized := make([]string, 0, len(providerIDs)+2)
sanitized := make([]string, 0, len(providerIDs)+3)
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
@@ -915,7 +908,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
for _, providerID := range []string{"qobuz", "tidal"} {
for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
if _, exists := seen[providerID]; exists {
continue
}
@@ -932,7 +925,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
return []string{"qobuz", "tidal"}
return []string{"deezer", "qobuz", "tidal"}
}
result := make([]string, len(metadataProviderPriority))
@@ -942,7 +935,7 @@ func GetMetadataProviderPriority() []string {
func isBuiltInProvider(providerID string) bool {
switch providerID {
case "tidal", "qobuz":
case "tidal", "qobuz", "deezer":
return true
default:
return false
@@ -958,19 +951,6 @@ func isBuiltInDownloadProvider(providerID string) bool {
}
}
func normalizeQualityForBuiltIn(quality string) string {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "alac", "hi_res_lossless", "lossless":
return "HI_RES_LOSSLESS"
case "atmos", "ac3", "dolby_atmos":
return "LOSSLESS"
case "aac", "aac-legacy":
return "LOSSLESS"
default:
return quality
}
}
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
deezerID := ""
tidalID := ""
@@ -1026,6 +1006,20 @@ func metadataTrackDedupKey(track ExtTrackMetadata) string {
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
switch providerID {
case "deezer":
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track")
if err != nil {
return nil, err
}
tracks := make([]ExtTrackMetadata, 0, len(results.Tracks))
for _, track := range results.Tracks {
tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer"))
}
return tracks, nil
case "qobuz":
return NewQobuzDownloader().SearchTracks(query, limit)
case "tidal":
@@ -1333,14 +1327,28 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.ISRC != "" &&
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
}
if req.Source != "" &&
!isBuiltInProvider(strings.ToLower(req.Source)) &&
selectedProvider == req.Source {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s' matching selected provider, trying it first\n", req.Source)
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
@@ -1422,12 +1430,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if result.DiscNumber > 0 {
resp.DiscNumber = result.DiscNumber
}
if result.TotalTracks > 0 {
resp.TotalTracks = result.TotalTracks
}
if result.TotalDiscs > 0 {
resp.TotalDiscs = result.TotalDiscs
}
if result.ReleaseDate != "" {
resp.ReleaseDate = result.ReleaseDate
}
@@ -1437,29 +1439,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if result.ISRC != "" {
resp.ISRC = result.ISRC
}
if result.Genre != "" {
resp.Genre = result.Genre
}
if result.Label != "" {
resp.Label = result.Label
}
if result.Copyright != "" {
resp.Copyright = result.Copyright
}
if result.Composer != "" {
resp.Composer = result.Composer
}
if result.LyricsLRC != "" {
resp.LyricsLRC = result.LyricsLRC
}
}
if req.TrackName != "" && resp.Title == "" {
resp.Title = req.TrackName
}
if req.ArtistName != "" && resp.Artist == "" {
resp.Artist = req.ArtistName
}
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
@@ -1478,18 +1459,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
resp.DiscNumber = req.DiscNumber
}
if req.TotalTracks > 0 && resp.TotalTracks == 0 {
resp.TotalTracks = req.TotalTracks
}
if req.TotalDiscs > 0 && resp.TotalDiscs == 0 {
resp.TotalDiscs = req.TotalDiscs
}
if req.CoverURL != "" && resp.CoverURL == "" {
resp.CoverURL = req.CoverURL
}
if req.Composer != "" && resp.Composer == "" {
resp.Composer = req.Composer
}
return resp, nil
}
@@ -1546,17 +1518,32 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInDownloadProvider(providerIDNormalized) {
req.OutputExt = ""
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extra metadata from ISRC: %s\n", req.ISRC)
enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre)
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
}
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
}
origQuality := req.Quality
req.Quality = normalizeQualityForBuiltIn(req.Quality)
result, err := tryBuiltInProvider(providerIDNormalized, req)
req.Quality = origQuality
if err == nil && result.Success {
result.Service = providerIDNormalized
if req.Label != "" {
@@ -1607,7 +1594,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
req.OutputExt = ""
outputPath := buildOutputPathForExtension(req, ext)
if req.ItemID != "" {
StartItemProgress(req.ItemID)
@@ -1920,9 +1906,6 @@ func canEmbedGenreLabel(filePath string) bool {
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
return false
}
if strings.ToLower(filepath.Ext(path)) != ".flac" {
return false
}
if !filepath.IsAbs(path) {
return false
}
+10 -13
View File
@@ -12,7 +12,7 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
SetMetadataProviderPriority([]string{"tidal"})
got := GetMetadataProviderPriority()
want := []string{"tidal", "qobuz"}
want := []string{"tidal", "deezer", "qobuz"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
@@ -185,10 +185,6 @@ func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
t.Fatalf("failed to create temp m4a file: %v", err)
}
if canEmbedGenreLabel("relative.flac") {
t.Fatal("expected relative path to be rejected")
@@ -199,9 +195,6 @@ func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
t.Fatal("expected missing file to be rejected")
}
if canEmbedGenreLabel(tempM4A) {
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
}
if !canEmbedGenreLabel(tempFile) {
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
}
@@ -215,7 +208,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
searchBuiltInMetadataTracksFunc = originalSearch
}()
SetMetadataProviderPriority([]string{"qobuz", "tidal"})
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
var calls []string
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
@@ -230,6 +223,10 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
}, nil
case "deezer":
return []ExtTrackMetadata{
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
}, nil
default:
return nil, nil
}
@@ -240,13 +237,13 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
if err != nil {
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
}
if len(tracks) != 2 {
t.Fatalf("unexpected track count: got %d want 2", len(tracks))
if len(tracks) != 3 {
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
}
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
t.Fatalf("unexpected track provider order: %+v", tracks)
}
if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
t.Fatalf("unexpected provider call order: %v", calls)
}
}
+1 -67
View File
@@ -5,7 +5,6 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -137,60 +136,12 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
return runtime
}
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return fallback
}
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
if !ok {
return fallback
}
seconds := parseExtensionTimeoutSeconds(raw)
if seconds <= 0 {
return fallback
}
if seconds < 5 {
seconds = 5
}
if seconds > 300 {
seconds = 300
}
return time.Duration(seconds) * time.Second
}
func parseExtensionTimeoutSeconds(raw interface{}) int {
switch v := raw.(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float32:
return int(v)
case float64:
return int(v)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return parsed
default:
return 0
}
}
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
r.activeDownloadMu.Lock()
defer r.activeDownloadMu.Unlock()
@@ -209,19 +160,6 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
return r.activeDownloadItemID
}
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
if req == nil {
return nil
}
itemID := r.getActiveDownloadItemID()
if itemID == "" {
return req
}
return req.WithContext(initDownloadCancel(itemID))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
@@ -475,10 +413,6 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
utilsObj.Set("appVersion", r.appVersion)
utilsObj.Set("appUserAgent", r.appUserAgent)
utilsObj.Set("sleep", r.sleep)
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
vm.Set("utils", utilsObj)
logObj := vm.NewObject()
-1
View File
@@ -458,7 +458,6 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
-1
View File
@@ -166,7 +166,6 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
-4
View File
@@ -81,7 +81,6 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -176,7 +175,6 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -286,7 +284,6 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -413,7 +410,6 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -69,7 +69,6 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if err != nil {
return r.createFetchError(err.Error())
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
-63
View File
@@ -249,69 +249,6 @@ func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(getRandomUserAgent())
}
func (r *extensionRuntime) appVersion(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(GetAppVersion())
}
func (r *extensionRuntime) appUserAgent(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(appUserAgent())
}
func (r *extensionRuntime) sleep(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(true)
}
sleepMs := 0
switch value := call.Arguments[0].Export().(type) {
case int64:
sleepMs = int(value)
case int32:
sleepMs = int(value)
case int:
sleepMs = value
case float64:
sleepMs = int(value)
default:
sleepMs = 0
}
if sleepMs <= 0 {
return r.vm.ToValue(true)
}
if sleepMs > 5*60*1000 {
sleepMs = 5 * 60 * 1000
}
itemID := r.getActiveDownloadItemID()
deadline := time.Now().Add(time.Duration(sleepMs) * time.Millisecond)
for {
if itemID != "" && isDownloadCancelled(itemID) {
return r.vm.ToValue(false)
}
remaining := time.Until(deadline)
if remaining <= 0 {
return r.vm.ToValue(true)
}
step := 100 * time.Millisecond
if remaining < step {
step = remaining
}
time.Sleep(step)
}
}
func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Value {
itemID := r.getActiveDownloadItemID()
if itemID == "" {
return r.vm.ToValue(false)
}
return r.vm.ToValue(isDownloadCancelled(itemID))
}
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
msg := r.formatLogArgs(call.Arguments)
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
+7 -19
View File
@@ -26,6 +26,7 @@ type storeExtension struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
@@ -82,6 +83,7 @@ type storeExtensionResponse struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url"`
IconURL string `json:"icon_url,omitempty"`
@@ -101,6 +103,7 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
Name: e.Name,
DisplayName: e.getDisplayName(),
Version: e.Version,
Author: e.Author,
Description: e.Description,
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
@@ -250,17 +253,7 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := NewHTTPClientWithTimeout(30 * time.Second)
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err)
return s.cache, nil
}
return nil, fmt.Errorf("failed to build registry request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
resp, err := client.Get(s.registryURL)
if err != nil {
if s.cache != nil {
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
@@ -355,13 +348,7 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := NewHTTPClientWithTimeout(5 * time.Minute)
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
if err != nil {
return fmt.Errorf("failed to build download request: %w", err)
}
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
resp, err := client.Get(ext.getDownloadURL())
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
@@ -494,7 +481,8 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) {
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
+3 -124
View File
@@ -1,10 +1,8 @@
package gobackend
import (
"net/http"
"path/filepath"
"testing"
"time"
"github.com/dop251/goja"
)
@@ -14,6 +12,7 @@ func TestParseManifest_Valid(t *testing.T) {
"name": "test-provider",
"displayName": "Test Provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"],
"permissions": {
@@ -47,6 +46,7 @@ func TestParseManifest_Valid(t *testing.T) {
func TestParseManifest_MissingName(t *testing.T) {
invalidManifest := `{
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension",
"type": ["metadata_provider"]
}`
@@ -61,6 +61,7 @@ func TestParseManifest_MissingType(t *testing.T) {
invalidManifest := `{
"name": "test-provider",
"version": "1.0.0",
"author": "Test Author",
"description": "A test extension"
}`
@@ -238,128 +239,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
if result.String() == "" {
t.Error("Expected non-empty JSON string")
}
result, err = vm.RunString(`utils.sleep(1)`)
if err != nil {
t.Fatalf("sleep failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected sleep to complete successfully")
}
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
result, err = vm.RunString(`utils.isDownloadCancelled()`)
if err != nil {
t.Fatalf("isDownloadCancelled failed: %v", err)
}
if !result.ToBoolean() {
t.Error("Expected active download cancellation to be visible to JS")
}
SetAppVersion("4.2.2")
t.Cleanup(func() {
SetAppVersion("")
})
result, err = vm.RunString(`utils.appVersion()`)
if err != nil {
t.Fatalf("appVersion failed: %v", err)
}
if got := result.String(); got != "4.2.2" {
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.appUserAgent()`)
if err != nil {
t.Fatalf("appUserAgent failed: %v", err)
}
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
}
result, err = vm.RunString(`utils.sleep(50)`)
if err != nil {
t.Fatalf("cancel-aware sleep failed: %v", err)
}
if result.ToBoolean() {
t.Error("Expected sleep to abort when download is cancelled")
}
}
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
cancelDownload("test-item")
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected bound request context to be cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error after cancellation")
}
}
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected pre-cancelled request context to stay cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error for pre-cancelled item")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
+4 -29
View File
@@ -6,8 +6,6 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
var (
@@ -19,42 +17,19 @@ var (
)
func sanitizeFilename(filename string) string {
sanitized := strings.ReplaceAll(filename, "/", " ")
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
sanitized := invalidChars.ReplaceAllString(filename, "_")
var builder strings.Builder
for _, r := range sanitized {
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
builder.WriteRune(r)
}
sanitized = builder.String()
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ". ")
sanitized = strings.Join(strings.Fields(sanitized), " ")
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
sanitized = strings.Trim(sanitized, ".")
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
if len(sanitized) > 200 {
sanitized = sanitized[:200]
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
sanitized = strings.Trim(sanitized, "_ ")
}
if sanitized == "" {
return "Unknown"
sanitized = "untitled"
}
return sanitized
-15
View File
@@ -83,18 +83,3 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
want := "Text In Quotes % Demo"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
got := sanitizeFilename(`<>:"/\|?*`)
if got != "Unknown" {
t.Fatalf("expected %q, got %q", "Unknown", got)
}
}
+2 -15
View File
@@ -16,19 +16,6 @@ import (
"time"
)
func userAgentForURL(u *url.URL) string {
if u == nil {
return getRandomUserAgent()
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if host == "api.zarz.moe" {
return appUserAgent()
}
return getRandomUserAgent()
}
func getRandomUserAgent() string {
chromeVersion := rand.Intn(26) + 120
chromeBuild := rand.Intn(1500) + 6000
@@ -238,7 +225,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
}
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
@@ -268,7 +255,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(reqCopy)
if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client {
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err != nil {
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
+3 -3
View File
@@ -101,7 +101,7 @@ func GetCloudflareBypassClient() *http.Client {
}
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgentForURL(req.URL))
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
if err == nil {
@@ -129,7 +129,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
return cloudflareBypassClient.Do(reqCopy)
}
@@ -155,7 +155,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
return cloudflareBypassClient.Do(reqCopy)
}
-26
View File
@@ -39,34 +39,8 @@ var DefaultLyricsProviders = []string{
var (
lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers
appVersionMu sync.RWMutex
appVersion string
)
func SetAppVersion(version string) {
normalized := strings.TrimSpace(version)
appVersionMu.Lock()
defer appVersionMu.Unlock()
appVersion = normalized
}
func GetAppVersion() string {
appVersionMu.RLock()
defer appVersionMu.RUnlock()
return appVersion
}
func appUserAgent() string {
version := GetAppVersion()
if version == "" {
return "SpotiFLAC-Mobile"
}
return "SpotiFLAC-Mobile/" + version
}
type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
+2 -3
View File
@@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
@@ -147,8 +147,7 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+1 -1
View File
@@ -72,7 +72,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+2 -2
View File
@@ -70,7 +70,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
for k, v := range neteaseHeaders {
req.Header.Set(k, v)
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+1 -1
View File
@@ -54,7 +54,7 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
+3 -345
View File
@@ -9,7 +9,6 @@ import (
_ "image/jpeg"
_ "image/png"
"io"
"math"
"os"
"path/filepath"
"regexp"
@@ -1245,281 +1244,6 @@ func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string
return nameValue, dataValue, nil
}
type m4aMetadataPath struct {
moov atomHeader
udta *atomHeader
meta atomHeader
ilst atomHeader
}
func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return m4aMetadataPath{}, fmt.Errorf("moov not found")
}
moovBodyStart := moov.offset + moov.headerSize
moovBodySize := moov.size - moov.headerSize
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
udtaCopy := udta
return m4aMetadataPath{
moov: moov,
udta: &udtaCopy,
meta: meta,
ilst: ilst,
}, nil
}
}
}
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
return m4aMetadataPath{
moov: moov,
meta: meta,
ilst: ilst,
}, nil
}
}
return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
func buildM4AAtom(typ string, payload []byte) []byte {
size := int64(8 + len(payload))
buf := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(buf[0:4], uint32(size))
copy(buf[4:8], []byte(typ))
copy(buf[8:], payload)
return buf
}
func buildM4AFreeformAtom(name, value string) []byte {
meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...)
namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...)
dataPayload := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text
copy(dataPayload[8:], []byte(value))
payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...)
payload = append(payload, buildM4AAtom("name", namePayload)...)
payload = append(payload, buildM4AAtom("data", dataPayload)...)
return buildM4AAtom("----", payload)
}
func buildITunNORMTag(trackGain, trackPeak string) string {
gainDb, ok := parseReplayGainDb(trackGain)
if !ok {
return ""
}
peakLinear, ok := parseReplayGainPeak(trackPeak)
if !ok {
return ""
}
clamp := func(v int64) int64 {
if v < 0 {
return 0
}
if v > 65534 {
return 65534
}
return v
}
g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0)))
g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0)))
peak := clamp(int64(math.Round(peakLinear * 32768.0)))
values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value)))
}
return strings.Join(parts, " ")
}
func parseReplayGainDb(value string) (float64, bool) {
match := regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`).FindStringSubmatch(strings.TrimSpace(value))
if len(match) < 2 {
return 0, false
}
parsed, err := strconv.ParseFloat(match[1], 64)
if err != nil {
return 0, false
}
return parsed, true
}
func parseReplayGainPeak(value string) (float64, bool) {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || parsed <= 0 {
return 0, false
}
return parsed, true
}
func collectM4AReplayGainFields(fields map[string]string) map[string]string {
result := map[string]string{}
if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" {
result["replaygain_track_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" {
result["replaygain_track_peak"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" {
result["replaygain_album_gain"] = value
}
if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" {
result["replaygain_album_peak"] = value
}
if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" {
result["iTunNORM"] = norm
}
return result
}
func writeAtomSize(buf []byte, header atomHeader, newSize int64) error {
if newSize <= 0 {
return fmt.Errorf("invalid size for %s", header.typ)
}
if header.headerSize == 16 {
if int(header.offset)+16 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1)
binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize))
return nil
}
if newSize > math.MaxUint32 {
return fmt.Errorf("atom %s too large for 32-bit header", header.typ)
}
if int(header.offset)+8 > len(buf) {
return io.ErrUnexpectedEOF
}
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize))
return nil
}
func EditM4AReplayGain(filePath string, fields map[string]string) error {
replayGainFields := collectM4AReplayGainFields(fields)
if len(replayGainFields) == 0 {
return nil
}
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
path, err := findM4AMetadataPath(f, info.Size())
if err != nil {
return err
}
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
bodyStart := path.ilst.offset + path.ilst.headerSize
bodyEnd := path.ilst.offset + path.ilst.size
newBody := make([]byte, 0, int(path.ilst.size))
targets := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
for pos := bodyStart; pos+8 <= bodyEnd; {
header, readErr := readAtomHeaderAt(f, pos, info.Size())
if readErr != nil {
return readErr
}
if header.size == 0 {
header.size = bodyEnd - pos
}
if header.size < header.headerSize {
return fmt.Errorf("invalid atom size for %s", header.typ)
}
keep := true
if header.typ == "----" {
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
if freeformErr == nil {
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
keep = false
}
}
}
if keep {
newBody = append(newBody, data[pos:pos+header.size]...)
}
pos += header.size
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
}
newIlst := buildM4AAtom("ilst", newBody)
updated := append([]byte{}, data[:path.ilst.offset]...)
updated = append(updated, newIlst...)
updated = append(updated, data[path.ilst.offset+path.ilst.size:]...)
delta := int64(len(newIlst)) - path.ilst.size
if err := writeAtomSize(updated, path.ilst, path.ilst.size+delta); err != nil {
return err
}
if err := writeAtomSize(updated, path.meta, path.meta.size+delta); err != nil {
return err
}
if path.udta != nil {
if err := writeAtomSize(updated, *path.udta, path.udta.size+delta); err != nil {
return err
}
}
if err := writeAtomSize(updated, path.moov, path.moov.size+delta); err != nil {
return err
}
return os.WriteFile(filePath, updated, 0o644)
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
@@ -1699,82 +1423,16 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23])
if atomType == "alac" {
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
if alacBitDepth > 0 {
bitDepth = alacBitDepth
}
if alacSampleRate > 0 {
sampleRate = alacSampleRate
}
}
}
if bitDepth <= 0 {
bitDepth = 16
if atomType == "alac" {
bitDepth = 24
}
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
}
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
if sampleOffset < 4 {
return 0, 0, false
}
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
if err != nil {
return 0, 0, false
}
childStart := sampleOffset + 32
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
if childStart >= childEnd {
return 0, 0, false
}
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "alac", fileSize)
if err != nil || !found {
return 0, 0, false
}
payloadSize := configHeader.size - configHeader.headerSize
if payloadSize <= 0 {
return 0, 0, false
}
payload := make([]byte, payloadSize)
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
return 0, 0, false
}
return parseALACSpecificConfig(payload)
}
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
if len(payload) < 24 {
return 0, 0, false
}
bitDepth := int(payload[5])
sampleRate := int(binary.BigEndian.Uint32(payload[20:24]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
// Some encoders prepend 4 bytes before the ALACSpecificConfig payload.
if len(payload) >= 28 {
bitDepth = int(payload[9])
sampleRate = int(binary.BigEndian.Uint32(payload[24:28]))
if bitDepth > 0 && sampleRate > 0 {
return bitDepth, sampleRate, true
}
}
return 0, 0, false
}
type atomHeader struct {
offset int64
size int64
-49
View File
@@ -1,49 +0,0 @@
package gobackend
import "testing"
func TestParseALACSpecificConfigStandardPayload(t *testing.T) {
payload := make([]byte, 24)
payload[5] = 24
payload[20] = 0x00
payload[21] = 0x00
payload[22] = 0xac
payload[23] = 0x44
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
if !ok {
t.Fatal("expected standard ALAC payload to parse")
}
if bitDepth != 24 {
t.Fatalf("bitDepth = %d, want 24", bitDepth)
}
if sampleRate != 44100 {
t.Fatalf("sampleRate = %d, want 44100", sampleRate)
}
}
func TestParseALACSpecificConfigPayloadWithLeadingFourBytes(t *testing.T) {
payload := make([]byte, 28)
payload[9] = 16
payload[24] = 0x00
payload[25] = 0x00
payload[26] = 0xbb
payload[27] = 0x80
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
if !ok {
t.Fatal("expected offset ALAC payload to parse")
}
if bitDepth != 16 {
t.Fatalf("bitDepth = %d, want 16", bitDepth)
}
if sampleRate != 48000 {
t.Fatalf("sampleRate = %d, want 48000", sampleRate)
}
}
func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
if _, _, ok := parseALACSpecificConfig(make([]byte, 12)); ok {
t.Fatal("expected short ALAC payload to be rejected")
}
}
+10 -1
View File
@@ -2655,8 +2655,17 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
}
}
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
if track == nil {
errMsg := "could not find matching track on Qobuz without identifier match"
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
track = nil
}
}
if track == nil {
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
if err != nil {
errMsg = err.Error()
}
+9 -7
View File
@@ -429,9 +429,11 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
t.Fatal("ISRC fallback should not run without an ISRC")
return nil, nil
}
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
t.Fatal("metadata fallback should not run")
return nil, nil
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
}
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
}
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
@@ -446,11 +448,11 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
}
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
if err == nil {
t.Fatalf("expected error, got track %+v", track)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if track != nil {
t.Fatalf("expected nil track, got %+v", track)
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
t.Fatalf("unexpected resolved track: %+v", track)
}
}
+3 -4
View File
@@ -147,7 +147,6 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
return nil, fmt.Errorf("failed to create resolve request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgentForURL(req.URL))
resp, err := s.client.Do(req)
if err != nil {
@@ -165,9 +164,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
}
var resolveResp struct {
Success bool `json:"success"`
ISRC string `json:"isrc"`
SongUrls map[string]json.RawMessage `json:"songUrls"`
Success bool `json:"success"`
ISRC string `json:"isrc"`
SongUrls map[string]json.RawMessage `json:"songUrls"`
}
if err := json.Unmarshal(body, &resolveResp); err != nil {
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
+15 -39
View File
@@ -51,7 +51,6 @@ type TidalTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
ISRC string `json:"isrc"`
Copyright string `json:"copyright"`
AudioQuality string `json:"audioQuality"`
TrackNumber int `json:"trackNumber"`
VolumeNumber int `json:"volumeNumber"`
@@ -136,7 +135,6 @@ type tidalPublicAlbum struct {
Type string `json:"type"`
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
Copyright string `json:"copyright"`
URL string `json:"url"`
NumberOfTracks int `json:"numberOfTracks"`
Explicit bool `json:"explicit"`
@@ -308,29 +306,6 @@ func tidalTrackArtistsDisplay(track *TidalTrack) string {
return strings.TrimSpace(track.Artist.Name)
}
func tidalTrackAlbumArtistDisplay(track *TidalTrack) string {
if track == nil {
return ""
}
if len(track.Artists) > 0 {
names := make([]string, 0, len(track.Artists))
for _, artist := range track.Artists {
if strings.ToUpper(strings.TrimSpace(artist.Type)) != "MAIN" {
continue
}
if trimmed := strings.TrimSpace(artist.Name); trimmed != "" {
names = append(names, trimmed)
}
}
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
return strings.TrimSpace(track.Artist.Name)
}
func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string {
if album == nil {
return ""
@@ -379,7 +354,7 @@ func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata {
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
@@ -402,7 +377,7 @@ func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata {
Artists: tidalTrackArtistsDisplay(track),
Name: strings.TrimSpace(track.Title),
AlbumName: strings.TrimSpace(track.Album.Title),
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
AlbumArtist: strings.TrimSpace(track.Artist.Name),
DurationMS: track.Duration * 1000,
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
@@ -432,7 +407,6 @@ func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata {
Artists: tidalAlbumArtistsDisplay(album),
ArtistId: artistID,
Images: tidalImageURL(album.Cover, "1280x1280"),
Copyright: strings.TrimSpace(album.Copyright),
}
}
@@ -714,10 +688,6 @@ func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *
func (t *TidalDownloader) GetAvailableAPIs() []string {
return []string{
"https://eu-central.monochrome.tf",
"https://us-west.monochrome.tf",
"https://api.monochrome.tf",
"https://monochrome-api.samidy.com",
"https://tidal-api.binimum.org",
"https://tidal.kinoplus.online",
"https://triton.squid.wtf",
@@ -1766,7 +1736,6 @@ type TidalDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
Copyright string
LyricsLRC string // LRC content for embedding in converted files
}
@@ -2080,6 +2049,18 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
}
}
if !gotTidalID && req.ISRC != "" {
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
trackID = directTrack.ID
gotTidalID = true
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
} else if directErr != nil {
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
}
}
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
@@ -2375,10 +2356,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if actualDiscNumber == 0 {
actualDiscNumber = track.VolumeNumber
}
copyright := strings.TrimSpace(req.Copyright)
if copyright == "" {
copyright = strings.TrimSpace(track.Copyright)
}
metadata := Metadata{
Title: req.TrackName,
@@ -2394,7 +2371,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: copyright,
Copyright: req.Copyright,
Composer: req.Composer,
}
@@ -2505,7 +2482,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
Copyright: copyright,
LyricsLRC: lyricsLRC,
}, nil
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 70 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+10 -53
View File
@@ -22,9 +22,6 @@ import Gobackend // Import Go framework
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
GobackendSetAppVersion(version)
}
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(
@@ -69,59 +66,9 @@ import Gobackend // Import Go framework
)
GeneratedPluginRegistrant.register(with: self)
if let url = launchOptions?[.url] as? URL {
_ = handleExtensionOAuthRedirect(url: url)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
@discardableResult
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
let host = (url.host ?? "").lowercased()
let path = url.path.lowercased()
let ok =
host == "callback" || host == "spotify-callback" || path.contains("callback")
guard ok else { return false }
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
}
let q = components.queryItems ?? []
let code =
q.first { $0.name == "code" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
let state =
q.first { $0.name == "state" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
if code.isEmpty { return false }
if state.isEmpty {
NSLog("SpotiFLAC: Extension OAuth redirect missing state (extension id)")
return false
}
streamQueue.async {
var err: NSError?
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
if let err = err {
NSLog(
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
}
}
return true
}
override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if handleExtensionOAuthRedirect(url: url) {
return true
}
return super.application(app, open: url, options: options)
}
deinit {
stopDownloadProgressStream()
stopLibraryScanProgressStream()
@@ -424,6 +371,16 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "searchDeezerAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "searchTidalAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '4.3.1';
static const String buildNumber = '126';
static const String version = '4.2.2';
static const String buildNumber = '123';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
-23
View File
@@ -17,7 +17,6 @@ import 'app_localizations_nl.dart';
import 'app_localizations_pt.dart';
import 'app_localizations_ru.dart';
import 'app_localizations_tr.dart';
import 'app_localizations_uk.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -120,7 +119,6 @@ abstract class AppLocalizations {
Locale('pt', 'PT'),
Locale('ru'),
Locale('tr'),
Locale('uk'),
Locale('zh'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
@@ -354,18 +352,6 @@ abstract class AppLocalizations {
/// **'Using extension: {extensionName}'**
String optionsUsingExtension(String extensionName);
/// Title for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Default Search Tab'**
String get optionsDefaultSearchTab;
/// Subtitle for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Choose which tab opens first for new search results.'**
String get optionsDefaultSearchTabSubtitle;
/// Hint to switch back to built-in providers
///
/// In en, this message translates to:
@@ -732,12 +718,6 @@ abstract class AppLocalizations {
/// **'PC source code'**
String get aboutPCSource;
/// Link to Keep Android Open campaign website
///
/// In en, this message translates to:
/// **'Keep Android Open'**
String get aboutKeepAndroidOpen;
/// Link to report bugs
///
/// In en, this message translates to:
@@ -5859,7 +5839,6 @@ class _AppLocalizationsDelegate
'pt',
'ru',
'tr',
'uk',
'zh',
].contains(locale.languageCode);
@@ -5924,8 +5903,6 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsRu();
case 'tr':
return AppLocalizationsTr();
case 'uk':
return AppLocalizationsUk();
case 'zh':
return AppLocalizationsZh();
}
+136 -149
View File
@@ -21,13 +21,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get navSettings => 'Einstellungen';
@override
String get navStore => 'Repo';
String get navStore => 'Store';
@override
String get homeTitle => 'Startseite';
@override
String get homeSubtitle => 'Unterstützte URL einfügen oder nach Namen suchen';
String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen';
@override
String get homeSupports =>
@@ -129,13 +129,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Erweiterung verwenden: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
@@ -184,21 +177,21 @@ class AppLocalizationsDe extends AppLocalizations {
'Disabled: no loudness normalization tags';
@override
String get optionsArtistTagMode => 'Künstler Tag-Modus';
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Wähle aus, wie mehrere Künstler in eingebetteten Tags geschrieben sind.';
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Einzelne beigefügte Werte';
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Einen Künstler wert wie \"Artist A, Artist B\" für maximale Spieler-Kompatibilität schreiben.';
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Tags für FLAC/Opus aufteilen';
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
@@ -220,11 +213,11 @@ class AppLocalizationsDe extends AppLocalizations {
'Parallele Downloads können Ratenlimitierung auslösen';
@override
String get optionsExtensionStore => 'Erweiterungs-Repo';
String get optionsExtensionStore => 'Erweiterungs-Store';
@override
String get optionsExtensionStoreSubtitle =>
'Repo-Tab in der Navigation anzeigen';
'Store-Tab in Navigation anzeigen';
@override
String get optionsCheckUpdates => 'Nach Updates suchen';
@@ -303,7 +296,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get extensionsUninstall => 'Deinstallieren';
@override
String get storeTitle => 'Erweiterungs-Repo';
String get storeTitle => 'Erweiterungs-Store';
@override
String get storeSearch => 'Erweiterungen suchen...';
@@ -348,9 +341,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutPCSource => 'PC Quellcode';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Problem melden';
@@ -586,7 +576,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get dialogImport => 'Importieren';
@override
String get dialogDownload => 'Herunterladen';
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Verwerfen';
@@ -819,37 +809,37 @@ class AppLocalizationsDe extends AppLocalizations {
String get searchAlbums => 'Alben';
@override
String get searchPlaylists => 'Playlists';
String get searchPlaylists => 'Playlisten';
@override
String get searchSortTitle => 'Ergebnisse sortieren';
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Standard';
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Titel (A-Z)';
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Titel (Z-A)';
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Künstler (A-Z)';
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Künstler (Z-A)';
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Dauer (kürzeste)';
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Dauer (längste)';
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Veröffentlichungsdatum (älteste)';
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Veröffentlichungsdatum (Neueste)';
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Abspielen';
@@ -1315,36 +1305,36 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeClearFilters => 'Filter entfernen';
@override
String get storeAddRepoTitle => 'Erweiterungs-Repository hinzufügen';
String get storeAddRepoTitle => 'Add Extension Repository';
@override
String get storeAddRepoDescription =>
'Gib eine GitHub Repository-URL ein, die eine Registry.json Datei enthält, um Erweiterungen zu durchsuchen und zu installieren.';
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
@override
String get storeRepoUrlLabel => 'Repository-URL';
String get storeRepoUrlLabel => 'Repository URL';
@override
String get storeRepoUrlHint => 'https://github.com/user/repo';
@override
String get storeRepoUrlHelper =>
'z.B. https://github.com/user/extensions-repo';
'e.g. https://github.com/user/extensions-repo';
@override
String get storeAddRepoButton => 'Repository hinzufügen';
String get storeAddRepoButton => 'Add Repository';
@override
String get storeChangeRepoTooltip => 'Repository ändern';
String get storeChangeRepoTooltip => 'Change repository';
@override
String get storeRepoDialogTitle => 'Erweiterungs-Repository';
String get storeRepoDialogTitle => 'Extension Repository';
@override
String get storeRepoDialogCurrent => 'Aktuelles Repository:';
String get storeRepoDialogCurrent => 'Current repository:';
@override
String get storeNewRepoUrlLabel => 'Neue Repository-URL';
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load repository';
@@ -1356,7 +1346,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Standard (Deezer)';
String get extensionDefaultProvider => 'Standard (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => 'Eingebaute Suche verwenden';
@@ -1517,36 +1507,36 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
@override
String get downloadLossy320 => 'Verlustbehaftet 320kbps';
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Verlustbehaftetes Format';
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Wähle das Ausgabeformat für Tidal 320kbps verlustbehaftete Downloads. Der ursprüngliche AAC Stream wird in das ausgewählte Format konvertiert.';
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle =>
'Beste Kompatibilität, ~10MB pro Titel';
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle => 'Beste Qualität, ~8MB pro Titel';
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Kleinste Größe, ~4MB pro Track';
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
@@ -1856,23 +1846,23 @@ class AppLocalizationsDe extends AppLocalizations {
'Bei der Suche nach vorhandenen Titeln anzeigen';
@override
String get libraryAutoScan => 'Auto-Scan';
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Aus';
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Bei jeder App Öffnung';
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Täglich';
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Wöchentlich';
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Aktionen';
@@ -1929,8 +1919,8 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Datein',
one: '1 Datei',
other: 'files',
one: 'file',
);
return '$_temp0';
}
@@ -1947,7 +1937,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get libraryScanning => 'Scannen...';
@override
String get libraryScanFinalizing => 'Bibliothek wird aktualisiert...';
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
@@ -2018,23 +2008,22 @@ class AppLocalizationsDe extends AppLocalizations {
String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadaten';
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Komplette Metadaten';
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Metadaten fehlen';
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Jahr fehlt';
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Genre fehlt';
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist =>
'Fehlender Album-Künstler';
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override
String get libraryFilterSort => 'Sortieren';
@@ -2066,7 +2055,7 @@ class AppLocalizationsDe extends AppLocalizations {
count,
locale: localeName,
other: 'vor $count Minuten',
one: 'vor 1 Minute',
one: 'vor $count Minute',
);
return '$_temp0';
}
@@ -2077,7 +2066,7 @@ class AppLocalizationsDe extends AppLocalizations {
count,
locale: localeName,
other: 'vor $count Stunden',
one: 'vor 1 Stunde',
one: 'vor $count Stunde',
);
return '$_temp0';
}
@@ -2143,7 +2132,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Repo tab to discover useful extensions';
'Im Store Tab findest du nützliche Erweiterungen';
@override
String get tutorialExtensionsTip2 =>
@@ -2397,11 +2386,11 @@ class AppLocalizationsDe extends AppLocalizations {
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
@override
String get queueFlacAction => 'Warteschlange FLAC';
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Suche Online-Matches für ausgewählte Titel und Playlists für FLAC-Downloads.\n\nVorhandene Dateien werden weder geändert noch gelöscht.\n\nNur eindeutige Treffer werden automatisch zur Warteschlange hinzugefügt.\n\n$count ausgewählt';
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
@@ -2427,8 +2416,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackConvertFormat => 'Format konvertieren';
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
String get trackConvertFormatSubtitle => 'In MP3 oder Opus konvertieren';
@override
String get trackConvertTitle => 'Audio konvertieren';
@@ -2456,7 +2444,7 @@ class AppLocalizationsDe extends AppLocalizations {
String sourceFormat,
String targetFormat,
) {
return 'Konvertieren von $sourceFormat in $targetFormat? (kein Qualitätsverlust)\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
@@ -2536,7 +2524,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get collectionLoved => 'Lieblingssongs';
@override
String get collectionPlaylists => 'Playlists';
String get collectionPlaylists => 'Playlisten';
@override
String get collectionPlaylist => 'Playlist';
@@ -2723,10 +2711,10 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Titel',
one: 'Titel',
other: 'tracks',
one: 'track',
);
return 'Konvertiere $count $_temp0 in $format? (kein Qualitätsverlust)\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
@@ -2753,24 +2741,24 @@ class AppLocalizationsDe extends AppLocalizations {
'Künstler-Ordner nur für Titel-Künstler';
@override
String get lyricsProvidersTitle => 'Lyrics-Anbieter';
String get lyricsProvidersTitle => 'Lyrics Providers';
@override
String get lyricsProvidersDescription =>
'Lyrics aktivieren, deaktivieren und neu ordnen. Anbieter werden von oben nach unten ausprobiert, bis Lyrics gefunden werden.';
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
@override
String get lyricsProvidersInfoText =>
'Erweiterungsanbieter werden immer vor eingebauten ausgeführt. Mindestens ein Anbieter muss aktiviert bleiben.';
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
return '($count) aktiviert';
return 'Enabled ($count)';
}
@override
String lyricsProvidersDisabledSection(int count) {
return '($count) deaktiviert';
return 'Disabled ($count)';
}
@override
@@ -2789,53 +2777,52 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get lyricsProviderNeteaseDesc =>
'NetEase Cloud Music (gut für asiatische Lieder)';
'NetEase Cloud Music (good for Asian songs)';
@override
String get lyricsProviderMusixmatchDesc =>
'Größte Lyrics-Datenbank (mehrsprachig)';
'Largest lyrics database (multi-language)';
@override
String get lyricsProviderAppleMusicDesc =>
'Wort-für-Wort-synchronisierte Lyrics (via Proxy)';
'Word-by-word synced lyrics (via proxy)';
@override
String get lyricsProviderQqMusicDesc =>
'QQ Music (gut für chinesische Lieder, via Proxy)';
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Erweiterungsanbieter';
String get lyricsProviderExtensionDesc => 'Extension provider';
@override
String get safMigrationTitle => 'Speicheraktualisierung erforderlich';
String get safMigrationTitle => 'Storage Update Required';
@override
String get safMigrationMessage1 =>
'SpotiFLAC verwendet jetzt Android Storage Access Framework (SAF) beim Herunterladen. Dies behebt Fehler bei Android 10+.';
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
@override
String get safMigrationMessage2 =>
'Bitte wähle dein Download-Ordner erneut aus, um zum neuen System zu wechseln.';
'Please select your download folder again to switch to the new storage system.';
@override
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Unterstützen';
String get settingsDonate => 'Donate';
@override
String get settingsDonateSubtitle =>
'Unterstütze die SpotiFLAC-Mobile Entwickler';
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
@override
String get tooltipLoveAll => 'Alle lieben';
String get tooltipLoveAll => 'Love All';
@override
String get tooltipAddToPlaylist => 'Zur Wiedergabeliste hinzufügen';
String get tooltipAddToPlaylist => 'Add to Playlist';
@override
String snackbarRemovedTracksFromLoved(int count) {
return '$count Titel von geliebt entfernt';
return 'Removed $count tracks from Loved';
}
@override
@@ -2844,7 +2831,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get dialogDownloadAllTitle => 'Alle Herunterladen';
String get dialogDownloadAllTitle => 'Download All';
@override
String dialogDownloadAllMessage(int count) {
@@ -2855,7 +2842,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@override
String get homeGoToAlbum => 'Zum Album gehen';
String get homeGoToAlbum => 'Go to Album';
@override
String get homeAlbumInfoUnavailable => 'Album info not available';
@@ -2874,7 +2861,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String snackbarError(String error) {
return 'Fehler: $error';
return 'Error: $error';
}
@override
@@ -2894,7 +2881,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
@override
String get storageModeSaf => 'SAF-Ordner';
String get storageModeSaf => 'SAF folder';
@override
String get storageModeSafSubtitle =>
@@ -2905,7 +2892,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Customize how your files are named.';
@override
String get downloadFilenameInsertTag => 'Tippe, um Tag einzufügen:';
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
@@ -2933,10 +2920,10 @@ class AppLocalizationsDe extends AppLocalizations {
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink-Region';
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Netzwerkkompatibilitätsmodus';
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
@@ -2979,7 +2966,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Append romanized lyrics when available';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Deaktiviert';
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
@@ -3011,10 +2998,10 @@ class AppLocalizationsDe extends AppLocalizations {
'Keep full Album Artist metadata value';
@override
String get downloadProvidersNoneEnabled => 'Keine aktiviert';
String get downloadProvidersNoneEnabled => 'None enabled';
@override
String get downloadMusixmatchLanguageCode => 'Sprach-Code';
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
@@ -3027,7 +3014,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WLAN + Mobile Daten';
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@override
String get downloadNetworkWifiOnlySubtitle =>
@@ -3041,23 +3028,23 @@ class AppLocalizationsDe extends AppLocalizations {
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@override
String get cacheRefresh => 'Aktualisieren';
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'Titel',
one: 'Titel',
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'Playlists',
one: 'Playlist',
other: 'playlists',
one: 'playlist',
);
return 'Lade $trackCount $_temp0 von $playlistCount $_temp1?';
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
@@ -3097,7 +3084,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Abrufen & Ausfüllen';
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@@ -3122,25 +3109,25 @@ class AppLocalizationsDe extends AppLocalizations {
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Titel';
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Künstler';
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Künstler';
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Datum';
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Titel #';
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disk #';
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@@ -3152,16 +3139,16 @@ class AppLocalizationsDe extends AppLocalizations {
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Urheberrecht';
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover-Art';
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'Alle';
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Nur leer';
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
@@ -3169,10 +3156,10 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get queueDownloadedHeader => 'Heruntergeladen';
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtere...';
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
@@ -3197,7 +3184,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get queueEmptyAlbums => 'Keine Album-Downloads';
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
@@ -3223,7 +3210,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Playlist zum Löschen wählen';
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@@ -3233,37 +3220,37 @@ class AppLocalizationsDe extends AppLocalizations {
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Audio wird analysiert...';
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit-Tiefe';
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Kanäle';
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Länge';
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Größe';
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamischer Bereich';
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Maximum';
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Proben';
String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
@@ -3271,7 +3258,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Anbieter';
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
@@ -3299,7 +3286,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Download abbrechen?';
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
@@ -3307,7 +3294,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get cancelDownloadKeep => 'Behalten';
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@@ -3322,22 +3309,22 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get errorLoadAlbum => 'Fehler beim Laden des Albums';
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Fehler beim Laden der Playlist';
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Fehler beim Laden des Interpreten';
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Fortschritt';
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Bibliotheksscan';
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@@ -3361,7 +3348,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get notifAlreadyInLibrary => 'Bereits in der Bibliothek';
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
@@ -3369,7 +3356,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get notifDownloadComplete => 'Download abgeschlossen';
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
@@ -3411,12 +3398,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String notifLibraryScanExcluded(int count) {
return '$count ausgeschlossen';
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count Fehler';
return '$count errors';
}
@override
@@ -3439,7 +3426,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get notifUpdateReady => 'Update bereit';
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
@@ -3447,7 +3434,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get notifUpdateFailed => 'Update fehlgeschlagen';
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
-10
View File
@@ -127,13 +127,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -341,9 +334,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
File diff suppressed because it is too large Load Diff
+33 -45
View File
@@ -21,13 +21,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get navSettings => 'Paramètres';
@override
String get navStore => 'Repo';
String get navStore => 'Magasin';
@override
String get homeTitle => 'Accueil';
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
@override
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
@@ -128,13 +128,6 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Utilisation de l\'extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
@@ -218,10 +211,10 @@ class AppLocalizationsFr extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
String get optionsExtensionStore => 'Extension Store';
@override
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
@@ -282,7 +275,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get extensionsTitle => 'Extensions';
@override
String get extensionsDisabled => 'Désactivée';
String get extensionsDisabled => 'Disabled';
@override
String extensionsVersion(String version) {
@@ -291,38 +284,38 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String extensionsAuthor(String author) {
return 'par $author';
return 'by $author';
}
@override
String get extensionsUninstall => 'Désinstaller';
@override
String get storeTitle => 'Extension Repo';
String get storeTitle => 'Magasin d\'extension';
@override
String get storeSearch => 'Recherche d\'extensions...';
@override
String get storeInstall => 'Installer';
String get storeInstall => 'Install';
@override
String get storeInstalled => 'Installé';
String get storeInstalled => 'Installed';
@override
String get storeUpdate => 'Mettre à jour';
String get storeUpdate => 'Update';
@override
String get aboutTitle => 'À propos de';
String get aboutTitle => 'About';
@override
String get aboutContributors => 'Contributeurs';
String get aboutContributors => 'Contributors';
@override
String get aboutMobileDeveloper => 'Développeur de la version mobile';
String get aboutMobileDeveloper => 'Mobile version developer';
@override
String get aboutOriginalCreator => 'Créateur de SpotiFLAC original';
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
@override
String get aboutLogoArtist =>
@@ -343,9 +336,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -362,7 +352,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutTelegramChannel => 'Telegram Channel';
@override
String get aboutTelegramChannelSubtitle => 'Annonces et mises à jour';
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
@override
String get aboutTelegramChat => 'Telegram Community';
@@ -520,11 +510,10 @@ class AppLocalizationsFr extends AppLocalizations {
'SpotiFLAC needs storage permission to save your downloaded music files.';
@override
String get setupNotificationGranted =>
'Autorisation de notifications accordée!';
String get setupNotificationGranted => 'Notification Permission Granted!';
@override
String get setupNotificationEnable => 'Activer les notifications';
String get setupNotificationEnable => 'Enable Notifications';
@override
String get setupFolderChoose => 'Choisissez le dossier pour télécharger';
@@ -534,39 +523,39 @@ class AppLocalizationsFr extends AppLocalizations {
'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.';
@override
String get setupSelectFolder => 'Sélectionner un dossier';
String get setupSelectFolder => 'Select Folder';
@override
String get setupEnableNotifications => 'Activer les notifications';
String get setupEnableNotifications => 'Enable Notifications';
@override
String get setupNotificationBackgroundDescription =>
'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
@override
String get setupSkipForNow => 'Ignorer pour le moment';
String get setupSkipForNow => 'Skip for now';
@override
String get setupNext => 'Suivant';
String get setupNext => 'Next';
@override
String get setupGetStarted => 'Démarrer';
String get setupGetStarted => 'Get Started';
@override
String get setupAllowAccessToManageFiles =>
'Veuillez activer \"Autoriser l\'accès à tous les fichiers\" sur l\'écran suivant.';
'Please enable \"Allow access to manage all files\" in the next screen.';
@override
String get dialogCancel => 'Annuler';
String get dialogCancel => 'Cancel';
@override
String get dialogSave => 'Sauvegarder';
String get dialogSave => 'Save';
@override
String get dialogDelete => 'Supprimer';
String get dialogDelete => 'Delete';
@override
String get dialogRetry => 'Réessayer';
String get dialogRetry => 'Retry';
@override
String get dialogClear => 'Clear';
@@ -578,7 +567,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Télécharger';
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -587,10 +576,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get dialogRemove => 'Remove';
@override
String get dialogUninstall => 'Désinstaller';
String get dialogUninstall => 'Uninstall';
@override
String get dialogDiscardChanges => 'Ignorer les modifications ?';
String get dialogDiscardChanges => 'Discard Changes?';
@override
String get dialogUnsavedChanges =>
@@ -1339,7 +1328,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -2117,7 +2106,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Repo tab to discover useful extensions';
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2398,8 +2387,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
+8 -19
View File
@@ -21,13 +21,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get navSettings => 'विकल्प';
@override
String get navStore => 'Repo';
String get navStore => 'Store';
@override
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -127,13 +127,6 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -216,10 +209,10 @@ class AppLocalizationsHi extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
String get optionsExtensionStore => 'Extension Store';
@override
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
@@ -296,7 +289,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get extensionsUninstall => 'Uninstall';
@override
String get storeTitle => 'Extension Repo';
String get storeTitle => 'Extension Store';
@override
String get storeSearch => 'Search extensions...';
@@ -341,9 +334,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -1336,7 +1326,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -2114,7 +2104,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Repo tab to discover useful extensions';
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2395,8 +2385,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
+27 -36
View File
@@ -27,7 +27,8 @@ class AppLocalizationsId extends AppLocalizations {
String get homeTitle => 'Beranda';
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
String get homeSubtitle =>
'Tempel URL yang didukung atau cari berdasarkan nama';
@override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
@@ -128,13 +129,6 @@ class AppLocalizationsId extends AppLocalizations {
return 'Menggunakan ekstensi: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
@@ -219,10 +213,10 @@ class AppLocalizationsId extends AppLocalizations {
'Unduhan paralel dapat memicu pembatasan rate';
@override
String get optionsExtensionStore => 'Extension Repo';
String get optionsExtensionStore => 'Repo Ekstensi';
@override
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
@override
String get optionsCheckUpdates => 'Periksa Pembaruan';
@@ -298,7 +292,7 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsUninstall => 'Copot';
@override
String get storeTitle => 'Extension Repo';
String get storeTitle => 'Repo Ekstensi';
@override
String get storeSearch => 'Cari ekstensi...';
@@ -343,9 +337,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutPCSource => 'Kode sumber PC';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Laporkan masalah';
@@ -744,15 +735,15 @@ class AppLocalizationsId extends AppLocalizations {
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
@override
String get errorUrlNotRecognized => 'Tautan tidak dikenali';
String get errorUrlNotRecognized => 'Link tidak dikenali';
@override
String get errorUrlNotRecognizedMessage =>
'Tautan ini tidak didukung. Pastikan URL sudah benar dan ekstensi yang kompatibel telah terpasang.';
'Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.';
@override
String get errorUrlFetchFailed =>
'Konten dari tautan ini gagal dimuat. Silakan coba lagi.';
'Gagal memuat konten dari link ini. Silakan coba lagi.';
@override
String errorMissingExtensionSource(String item) {
@@ -940,15 +931,15 @@ class AppLocalizationsId extends AppLocalizations {
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
@override
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
String get providerPriorityFallbackExtensionsTitle => 'Fallback Ekstensi';
@override
String get providerPriorityFallbackExtensionsDescription =>
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
'Pilih ekstensi unduhan terpasang mana yang boleh dipakai saat fallback otomatis. Provider bawaan tetap mengikuti urutan prioritas di atas.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Only enabled extensions with download-provider capability are listed here.';
'Hanya ekstensi aktif dengan kemampuan download provider yang ditampilkan di sini.';
@override
String get providerBuiltIn => 'Bawaan';
@@ -1333,7 +1324,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load repository';
String get storeLoadError => 'Gagal memuat repo';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1342,7 +1333,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
@@ -1448,7 +1439,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get extensionsFallbackSubtitle =>
'Choose which installed download extensions can be used as fallback';
'Pilih ekstensi unduhan terpasang yang boleh dipakai saat fallback';
@override
String get extensionsNoDownloadProvider =>
@@ -2123,7 +2114,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Repo tab to discover useful extensions';
'Buka tab Repo untuk menemukan ekstensi yang berguna';
@override
String get tutorialExtensionsTip2 =>
@@ -2374,25 +2365,25 @@ class AppLocalizationsId extends AppLocalizations {
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
String get queueFlacAction => 'Antrekan FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
return 'Mencari kecocokan FLAC... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
}
@override
@@ -2405,7 +2396,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Konversi ke MP3, Opus, ALAC, atau FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2433,12 +2424,12 @@ class AppLocalizationsId extends AppLocalizations {
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
'Konversi lossless — tanpa kehilangan kualitas';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2892,19 +2883,19 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
'Buat folder sumber playlist';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'Unduhan dari playlist hanya memakai struktur folder normal.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
+8 -19
View File
@@ -21,13 +21,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get navSettings => '設定';
@override
String get navStore => 'Repo';
String get navStore => 'ストア';
@override
String get homeTitle => 'ホーム';
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
String get homeSubtitle => 'Spotify のリンクを貼り付けるか、名前で検索します';
@override
String get homeSupports => 'サポート: トラック、アルバム、プレイリスト、アーティスト、URL';
@@ -127,13 +127,6 @@ class AppLocalizationsJa extends AppLocalizations {
return '拡張の使用: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -214,10 +207,10 @@ class AppLocalizationsJa extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
String get optionsExtensionStore => '拡張ストア';
@override
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
String get optionsExtensionStoreSubtitle => 'ナビゲーションにストアタブを表示';
@override
String get optionsCheckUpdates => '更新を確認';
@@ -293,7 +286,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get extensionsUninstall => 'アンインストール';
@override
String get storeTitle => 'Extension Repo';
String get storeTitle => '拡張ストア';
@override
String get storeSearch => '拡張を検索...';
@@ -337,9 +330,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutPCSource => 'PC 版のソースコード';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => '問題を報告する';
@@ -1330,7 +1320,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
@@ -2101,7 +2091,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Repo tab to discover useful extensions';
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2382,8 +2372,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackConvertFormat => '変換の形式';
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
String get trackConvertFormatSubtitle => 'MP3 または Opus に変換';
@override
String get trackConvertTitle => 'オーディオを変換';
+8 -19
View File
@@ -21,13 +21,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get navSettings => 'Settings';
@override
String get navStore => 'Repo';
String get navStore => 'Store';
@override
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
@override
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
@@ -125,13 +125,6 @@ class AppLocalizationsKo extends AppLocalizations {
return '확장 기능을 사용: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
@@ -209,10 +202,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다';
@override
String get optionsExtensionStore => 'Extension Repo';
String get optionsExtensionStore => '확장 기능 스토어';
@override
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
String get optionsExtensionStoreSubtitle => '탐색 메뉴에 스토어 탭 표시';
@override
String get optionsCheckUpdates => '업데이트 확인';
@@ -286,7 +279,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get extensionsUninstall => '삭제';
@override
String get storeTitle => 'Extension Repo';
String get storeTitle => '확장 기능 스토어';
@override
String get storeSearch => '확장 기능 검색';
@@ -330,9 +323,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutPCSource => 'PC 소스 코드';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => '문제 신고';
@@ -1316,7 +1306,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -2094,7 +2084,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Repo tab to discover useful extensions';
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2375,8 +2365,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
+8 -19
View File
@@ -21,13 +21,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get navSettings => 'Settings';
@override
String get navStore => 'Repo';
String get navStore => 'Store';
@override
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -127,13 +127,6 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
@@ -216,10 +209,10 @@ class AppLocalizationsNl extends AppLocalizations {
'Parallel downloaden kan leiden tot rate-limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
String get optionsExtensionStore => 'Extension Store';
@override
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
@@ -296,7 +289,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get extensionsUninstall => 'Uninstall';
@override
String get storeTitle => 'Extension Repo';
String get storeTitle => 'Extension Store';
@override
String get storeSearch => 'Search extensions...';
@@ -341,9 +334,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutPCSource => 'PC source code';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Report an issue';
@@ -1336,7 +1326,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -2114,7 +2104,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Repo tab to discover useful extensions';
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -2395,8 +2385,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
File diff suppressed because it is too large Load Diff
+20 -30
View File
@@ -21,13 +21,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get navSettings => 'Настройки';
@override
String get navStore => 'Repo';
String get navStore => 'Магазин';
@override
String get homeTitle => 'Главная';
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
String get homeSubtitle => 'Вставьте ссылку Spotify или ищите по названию';
@override
String get homeSupports =>
@@ -129,13 +129,6 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Используется расширение: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Нажмите Deezer или Spotify для возврата с расширения';
@@ -221,10 +214,11 @@ class AppLocalizationsRu extends AppLocalizations {
'Параллельные загрузки могут вызвать ограничение скорости';
@override
String get optionsExtensionStore => 'Extension Repo';
String get optionsExtensionStore => 'Магазин расширений';
@override
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
String get optionsExtensionStoreSubtitle =>
'Показывать вкладку Магазин в гл. меню';
@override
String get optionsCheckUpdates => 'Проверить обновления';
@@ -301,7 +295,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get extensionsUninstall => 'Удалить';
@override
String get storeTitle => 'Extension Repo';
String get storeTitle => 'Магазин расширений';
@override
String get storeSearch => 'Поиск расширений...';
@@ -346,9 +340,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutPCSource => 'Исходный код ПК версии';
@override
String get aboutKeepAndroidOpen => 'Keep Android Open';
@override
String get aboutReportIssue => 'Сообщить о проблеме';
@@ -635,9 +626,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
}
@@ -690,9 +681,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалено $count $_temp0';
}
@@ -1160,9 +1151,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -1356,7 +1347,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle =>
@@ -1669,9 +1660,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
}
@@ -1693,9 +1684,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0';
}
@@ -1926,9 +1917,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return '$_temp0';
}
@@ -2082,9 +2073,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count минут',
one: '1 минуту',
many: '$count минут',
few: '$count минуты',
one: '$count минуту',
);
return '$_temp0 назад';
}
@@ -2095,9 +2086,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count часов',
one: '1 час',
many: '$count часов',
few: '$count часа',
one: '$count час',
);
return '$_temp0 назад';
}
@@ -2163,7 +2154,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Repo tab to discover useful extensions';
'Просмотрите вкладку Магазина, чтобы найти полезные расширения';
@override
String get tutorialExtensionsTip2 =>
@@ -2447,8 +2438,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackConvertFormat => 'Переконвертировать формат';
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
String get trackConvertFormatSubtitle => 'Конвертировать в MP3 или Opus';
@override
String get trackConvertTitle => 'Конвертировать аудио';
@@ -2579,9 +2569,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -2698,9 +2688,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Отправить $count $_temp0';
}
@@ -2715,9 +2705,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Конвертировать $count $_temp0';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -1336
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -158,14 +158,6 @@
}
}
},
"optionsDefaultSearchTab": "Default Search Tab",
"@optionsDefaultSearchTab": {
"description": "Title for the preferred default search tab setting"
},
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
@@ -430,10 +422,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
-4
View File
@@ -362,10 +362,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
+40 -1356
View File
File diff suppressed because it is too large Load Diff
+33 -1349
View File
File diff suppressed because it is too large Load Diff
+9 -1325
View File
File diff suppressed because it is too large Load Diff
+43 -1262
View File
File diff suppressed because it is too large Load Diff
+9 -1325
View File
File diff suppressed because it is too large Load Diff
+9 -1325
View File
File diff suppressed because it is too large Load Diff
+9 -1325
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -362,10 +362,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
+15 -1331
View File
File diff suppressed because it is too large Load Diff
+20 -1336
View File
File diff suppressed because it is too large Load Diff
+279 -1595
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-4
View File
@@ -362,10 +362,6 @@
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutKeepAndroidOpen": "Keep Android Open",
"@aboutKeepAndroidOpen": {
"description": "Link to Keep Android Open campaign website"
},
"aboutReportIssue": "Report an issue",
"@aboutReportIssue": {
"description": "Link to report bugs"
+10 -1326
View File
File diff suppressed because it is too large Load Diff
+10 -1326
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -20,7 +20,6 @@ const List<Locale> filteredSupportedLocales = <Locale>[
Locale('pt', 'PT'),
Locale('ja'),
Locale('tr'),
Locale('uk'),
];
/// Set of locale codes for quick lookup.
@@ -32,5 +31,4 @@ const Set<String> filteredLocaleCodes = <String>{
'pt_PT',
'ja',
'tr',
'uk',
};
-4
View File
@@ -35,7 +35,6 @@ class AppSettings {
final bool useExtensionProviders;
final List<String>? downloadFallbackExtensionIds;
final String? searchProvider;
final String defaultSearchTab;
final String? homeFeedProvider;
final bool separateSingles;
final String singleFilenameFormat;
@@ -112,7 +111,6 @@ class AppSettings {
this.useExtensionProviders = true,
this.downloadFallbackExtensionIds,
this.searchProvider,
this.defaultSearchTab = 'all',
this.homeFeedProvider,
this.separateSingles = false,
this.singleFilenameFormat = '{title} - {artist}',
@@ -178,7 +176,6 @@ class AppSettings {
bool clearDownloadFallbackExtensionIds = false,
String? searchProvider,
bool clearSearchProvider = false,
String? defaultSearchTab,
String? homeFeedProvider,
bool clearHomeFeedProvider = false,
bool? separateSingles,
@@ -245,7 +242,6 @@ class AppSettings {
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
defaultSearchTab: defaultSearchTab ?? this.defaultSearchTab,
homeFeedProvider: clearHomeFeedProvider
? null
: (homeFeedProvider ?? this.homeFeedProvider),
-2
View File
@@ -40,7 +40,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
?.map((e) => e as String)
.toList(),
searchProvider: json['searchProvider'] as String?,
defaultSearchTab: json['defaultSearchTab'] as String? ?? 'all',
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
singleFilenameFormat:
@@ -112,7 +111,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'useExtensionProviders': instance.useExtensionProviders,
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
'searchProvider': instance.searchProvider,
'defaultSearchTab': instance.defaultSearchTab,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles,
'singleFilenameFormat': instance.singleFilenameFormat,
+17 -251
View File
@@ -26,10 +26,7 @@ final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
final _trimDotsAndSpacesRegex = RegExp(r'^[. ]+|[. ]+$');
final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$');
final _multiWhitespaceRegex = RegExp(r'\s+');
final _multiUnderscoreRegex = RegExp(r'_+');
final _trailingDotsRegex = RegExp(r'\.+$');
/// log10 helper using dart:math's natural log.
double _log10(num x) => log(x) / ln10;
@@ -574,7 +571,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (trimmed.startsWith('content://')) return true;
return trimmed.endsWith('.flac') ||
trimmed.endsWith('.m4a') ||
trimmed.endsWith('.mp4') ||
trimmed.endsWith('.aac') ||
trimmed.endsWith('.mp3') ||
trimmed.endsWith('.opus') ||
@@ -596,7 +592,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
!hasResolvedSpecs &&
(trimmedPath.endsWith('.flac') ||
trimmedPath.endsWith('.m4a') ||
trimmedPath.endsWith('.mp4') ||
trimmedPath.endsWith('.aac') ||
trimmedPath.startsWith('content://'));
@@ -2170,29 +2165,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
String _sanitizeFolderName(String name) {
final buffer = StringBuffer();
for (final rune in name.runes) {
if (rune < 0x20 || rune == 0x7f) {
continue;
}
final char = String.fromCharCode(rune);
if (_invalidFolderChars.hasMatch(char)) {
buffer.write(' ');
continue;
}
buffer.write(char);
}
var sanitized = buffer.toString().trim();
sanitized = sanitized.replaceAll(_trimDotsAndSpacesRegex, '');
sanitized = sanitized.replaceAll(_multiWhitespaceRegex, ' ');
sanitized = sanitized.replaceAll(_multiUnderscoreRegex, '_');
sanitized = sanitized.replaceAll(_trimUnderscoresAndSpacesRegex, '');
if (sanitized.isEmpty) {
return 'Unknown';
}
return sanitized;
return name
.replaceAll(_invalidFolderChars, '_')
.replaceAll(_trailingDotsRegex, '')
.trim();
}
static final _featuredArtistPattern = RegExp(
@@ -2346,59 +2322,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return '$prefix/$suffix';
}
String? _extensionPreferredOutputExt(String service) {
final normalizedService = service.trim().toLowerCase();
if (normalizedService.isEmpty) return null;
final extensionState = ref.read(extensionProvider);
for (final ext in extensionState.extensions) {
if (!ext.enabled || !ext.hasDownloadProvider) continue;
if (ext.id.toLowerCase() != normalizedService) continue;
final preferred = ext.preferredDownloadOutputExtension;
if (preferred == null) return null;
final normalized = preferred.startsWith('.')
? preferred.toLowerCase()
: '.${preferred.toLowerCase()}';
if (normalized == '.mp4') {
return '.m4a';
}
const allowed = <String>{'.flac', '.m4a', '.mp3', '.opus'};
if (allowed.contains(normalized)) {
return normalized;
}
return null;
}
return null;
}
bool _extensionPreservesNativeOutputExt(String service, String ext) {
final normalizedService = service.trim().toLowerCase();
final normalizedExt = ext.trim().toLowerCase();
if (normalizedService.isEmpty || normalizedExt.isEmpty) return false;
final extensionState = ref.read(extensionProvider);
return extensionState.extensions.any(
(ext) =>
ext.enabled &&
ext.hasDownloadProvider &&
ext.id.toLowerCase() == normalizedService &&
ext.preservedNativeOutputExtensions.contains(normalizedExt),
);
}
String _determineOutputExt(String quality, String service) {
final extensionPreferred = _extensionPreferredOutputExt(service);
if (extensionPreferred != null) {
return extensionPreferred;
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a';
}
final q = quality.toLowerCase();
if (q == 'alac' || q.startsWith('aac')) return '.m4a';
if (q.startsWith('opus')) return '.opus';
if (q.startsWith('mp3')) return '.mp3';
return '.flac';
@@ -2407,7 +2335,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String _mimeTypeForExt(String ext) {
switch (ext.toLowerCase()) {
case '.m4a':
case '.mp4':
return 'audio/mp4';
case '.mp3':
return 'audio/mpeg';
@@ -3710,8 +3637,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
) {
final backendTrackNum = _parsePositiveInt(backendResult['track_number']);
final backendDiscNum = _parsePositiveInt(backendResult['disc_number']);
final backendTotalTracks = _parsePositiveInt(backendResult['total_tracks']);
final backendTotalDiscs = _parsePositiveInt(backendResult['total_discs']);
final backendYear = normalizeOptionalString(
backendResult['release_date'] as String?,
);
@@ -3739,9 +3664,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
backendIsrc != null ||
backendCoverUrl != null ||
backendAlbumArtist != null ||
backendComposer != null ||
backendTotalTracks != null ||
backendTotalDiscs != null;
backendComposer != null;
if (!hasOverrides) {
return baseTrack;
@@ -3760,12 +3683,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isrc: backendIsrc ?? baseTrack.isrc,
trackNumber: backendTrackNum ?? baseTrack.trackNumber,
discNumber: backendDiscNum ?? baseTrack.discNumber,
totalDiscs: backendTotalDiscs ?? baseTrack.totalDiscs,
totalDiscs: baseTrack.totalDiscs,
releaseDate: backendYear ?? baseTrack.releaseDate,
deezerId: baseTrack.deezerId,
availability: baseTrack.availability,
albumType: baseTrack.albumType,
totalTracks: backendTotalTracks ?? baseTrack.totalTracks,
totalTracks: baseTrack.totalTracks,
composer: backendComposer ?? baseTrack.composer,
source: baseTrack.source,
);
@@ -3773,8 +3696,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
/// Unified metadata, cover, lyrics, and ReplayGain embedding for all formats.
///
/// [format] must be one of `'flac'`, `'m4a'`, `'mp3'`, or `'opus'`.
/// [writeExternalLrc] only applies to FLAC and M4A (non-SAF paths handle LRC separately).
/// [format] must be one of `'flac'`, `'mp3'`, or `'opus'`.
/// [writeExternalLrc] only applies to FLAC (non-SAF paths handle LRC separately).
Future<void> _embedMetadataToFile(
String filePath,
Track track, {
@@ -3794,7 +3717,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
final isFlac = format == 'flac';
final isM4a = format == 'm4a';
final isMp3 = format == 'mp3';
// Cover download
@@ -3918,11 +3840,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (shouldEmbedLyrics && lrcContent != null) {
metadata['LYRICS'] = lrcContent;
if (isFlac || isMp3) metadata['UNSYNCEDLYRICS'] = lrcContent;
} else if ((isFlac || isM4a) && !shouldEmbedLyrics) {
} else if (isFlac && !shouldEmbedLyrics) {
metadata['LYRICS'] = '';
if (isFlac) {
metadata['UNSYNCEDLYRICS'] = '';
}
metadata['UNSYNCEDLYRICS'] = '';
}
if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) {
@@ -3936,14 +3856,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
ReplayGainResult? scannedReplayGain;
// ReplayGain (MP3/Opus/M4A: scan before FFmpeg, add to metadata)
// ReplayGain (MP3/Opus: scan before FFmpeg, add to metadata)
if (settings.embedReplayGain && !isFlac) {
try {
final rgResult = await FFmpegService.scanReplayGain(filePath);
if (rgResult != null) {
scannedReplayGain = rgResult;
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
_log.d(
@@ -3969,12 +3886,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
metadata: metadata,
artistTagMode: settings.artistTagMode,
);
} else if (isM4a) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: filePath,
coverPath: validCover,
metadata: metadata,
);
} else if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: filePath,
@@ -3996,20 +3907,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.w('FFmpeg $format metadata embed failed');
}
if (isM4a && settings.embedReplayGain && scannedReplayGain != null) {
try {
await PlatformBridge.editFileMetadata(filePath, {
'replaygain_track_gain': scannedReplayGain.trackGain,
'replaygain_track_peak': scannedReplayGain.trackPeak,
});
_log.d(
'ReplayGain compatibility tags written for $format: gain=${scannedReplayGain.trackGain}, peak=${scannedReplayGain.trackPeak}',
);
} catch (e) {
_log.w('Failed to write native ReplayGain tags for $format: $e');
}
}
// FLAC post-processing
if (isFlac) {
if (settings.artistTagMode == artistTagModeSplitVorbis) {
@@ -4898,10 +4795,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final actualService =
((result['service'] as String?)?.toLowerCase()) ??
item.service.toLowerCase();
final preferredOutputExt = _extensionPreferredOutputExt(actualService);
final shouldPreserveNativeM4a =
preferredOutputExt == '.m4a' ||
_extensionPreservesNativeOutputExt(actualService, '.m4a');
final decryptionDescriptor =
DownloadDecryptionDescriptor.fromDownloadResult(result);
trackToDownload = _buildTrackForMetadataEmbedding(
@@ -5027,7 +4920,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isM4aFile =
filePath != null &&
(filePath.endsWith('.m4a') ||
filePath.endsWith('.mp4') ||
(mimeType != null && mimeType.contains('mp4')));
final isFlacFile =
filePath != null &&
@@ -5043,7 +4935,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (shouldForceTidalSafM4aHandling) {
_log.w(
'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; converting it back to FLAC.',
'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; forcing FFmpeg conversion to FLAC.',
);
}
@@ -5160,64 +5052,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
}
} else if (shouldPreserveNativeM4a) {
_log.d('M4A file detected (SAF), preserving native container...');
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
try {
if (metadataEmbeddingEnabled) {
updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.99,
);
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
await _embedMetadataToFile(
tempPath,
finalTrack,
format: 'm4a',
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
downloadService: item.service,
writeExternalLrc: false,
);
}
final newFileName = '${safBaseName ?? 'track'}.m4a';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt('.m4a'),
srcPath: tempPath,
);
if (newUri != null) {
if (newUri != currentFilePath) {
await _deleteSafFile(currentFilePath);
}
filePath = newUri;
finalSafFileName = newFileName;
} else {
_log.w('Failed to write M4A to SAF, keeping original');
}
} catch (e) {
_log.w('SAF native M4A handling failed: $e');
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
}
}
} else {
_log.d('M4A file detected (SAF), converting to FLAC...');
final tempPath = await _copySafToTemp(currentFilePath);
@@ -5373,61 +5207,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} else if (shouldPreserveNativeM4a) {
_log.d('M4A file detected, preserving native container...');
try {
var targetPath = currentFilePath;
final file = File(targetPath);
if (!await file.exists()) {
_log.e('File does not exist at path: $filePath');
} else {
if (!(targetPath.toLowerCase().endsWith('.m4a') ||
targetPath.toLowerCase().endsWith('.mp4'))) {
final renamedPath = targetPath.replaceAll(
RegExp(r'\.[^.]+$'),
'.m4a',
);
final finalRenamedPath = renamedPath == targetPath
? '$targetPath.m4a'
: renamedPath;
await file.rename(finalRenamedPath);
targetPath = finalRenamedPath;
filePath = finalRenamedPath;
} else {
filePath = targetPath;
}
if (metadataEmbeddingEnabled) {
updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.99,
);
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
await _embedMetadataToFile(
targetPath,
finalTrack,
format: 'm4a',
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
downloadService: item.service,
);
}
}
} catch (e) {
_log.w('Native M4A handling failed: $e');
}
} else {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
@@ -5820,15 +5599,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendYear = result['release_date'] as String?;
final backendTrackNum = result['track_number'] as int?;
final backendDiscNum = result['disc_number'] as int?;
final backendTotalTracks = result['total_tracks'] as int?;
final backendTotalDiscs = result['total_discs'] as int?;
final backendBitDepth = result['actual_bit_depth'] as int?;
final backendSampleRate = result['actual_sample_rate'] as int?;
final backendISRC = result['isrc'] as String?;
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
final backendComposer = result['composer'] as String?;
final effectiveGenre =
normalizeOptionalString(backendGenre) ??
normalizeOptionalString(genre) ??
@@ -5849,7 +5625,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
filePath.startsWith('content://') ||
lowerFilePath.endsWith('.flac') ||
lowerFilePath.endsWith('.m4a') ||
lowerFilePath.endsWith('.mp4') ||
lowerFilePath.endsWith('.aac') ||
lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') ||
@@ -5937,17 +5712,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackNumber: (backendTrackNum != null && backendTrackNum > 0)
? backendTrackNum
: trackToDownload.trackNumber,
totalTracks:
(backendTotalTracks != null && backendTotalTracks > 0)
? backendTotalTracks
: trackToDownload.totalTracks,
totalTracks: trackToDownload.totalTracks,
discNumber: (backendDiscNum != null && backendDiscNum > 0)
? backendDiscNum
: trackToDownload.discNumber,
totalDiscs:
(backendTotalDiscs != null && backendTotalDiscs > 0)
? backendTotalDiscs
: trackToDownload.totalDiscs,
totalDiscs: trackToDownload.totalDiscs,
duration: trackToDownload.duration,
releaseDate: (backendYear != null && backendYear.isNotEmpty)
? backendYear
@@ -5956,10 +5725,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
genre: effectiveGenre,
composer:
(backendComposer != null && backendComposer.isNotEmpty)
? backendComposer
: trackToDownload.composer,
composer: trackToDownload.composer,
label: effectiveLabel,
copyright: effectiveCopyright,
),
+17 -114
View File
@@ -20,6 +20,7 @@ class Extension {
final String name;
final String displayName;
final String version;
final String author;
final String description;
final bool enabled;
final String status;
@@ -44,6 +45,7 @@ class Extension {
required this.name,
required this.displayName,
required this.version,
required this.author,
required this.description,
required this.enabled,
required this.status,
@@ -71,6 +73,7 @@ class Extension {
displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
enabled: json['enabled'] as bool? ?? false,
status: json['status'] as String? ?? 'loaded',
@@ -121,6 +124,7 @@ class Extension {
String? name,
String? displayName,
String? version,
String? author,
String? description,
bool? enabled,
String? status,
@@ -145,6 +149,7 @@ class Extension {
name: name ?? this.name,
displayName: displayName ?? this.displayName,
version: version ?? this.version,
author: author ?? this.author,
description: description ?? this.description,
enabled: enabled ?? this.enabled,
status: status ?? this.status,
@@ -173,26 +178,6 @@ class Extension {
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
String? get preferredDownloadOutputExtension {
final value = capabilities['downloadOutputExtension'];
if (value is! String) return null;
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
List<String> get preservedNativeOutputExtensions {
final value = capabilities['preserveNativeOutputExtensions'];
if (value is! List) return const [];
final normalized = <String>[];
for (final item in value) {
if (item is! String) continue;
final trimmed = item.trim().toLowerCase();
if (trimmed.isEmpty) continue;
normalized.add(trimmed.startsWith('.') ? trimmed : '.$trimmed');
}
return normalized;
}
}
class SearchFilter {
@@ -496,10 +481,8 @@ class ExtensionState {
}
class ExtensionNotifier extends Notifier<ExtensionState> {
static const _builtInMetadataProviders = ['qobuz', 'tidal'];
AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
@override
ExtensionState build() {
@@ -537,13 +520,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> initialize(String extensionsDir, String dataDir) async {
if (state.isInitialized) return;
if (_initializationCompleter != null) {
await _initializationCompleter!.future;
return;
}
final completer = Completer<void>();
_initializationCompleter = completer;
state = state.copyWith(isLoading: true, error: null);
@@ -555,8 +531,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
error: null,
);
_log.i('Extension system disabled on this platform');
completer.complete();
_initializationCompleter = null;
return;
}
@@ -570,32 +544,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} catch (e) {
_log.e('Failed to initialize extension system: $e');
state = state.copyWith(isLoading: false, error: e.toString());
} finally {
if (!completer.isCompleted) {
completer.complete();
}
if (identical(_initializationCompleter, completer)) {
_initializationCompleter = null;
}
}
}
Future<void> waitForInitialization({
Duration timeout = const Duration(seconds: 30),
}) async {
if (state.isInitialized || !PlatformBridge.supportsExtensionSystem) {
return;
}
final future = _initializationCompleter?.future;
if (future == null) {
return;
}
try {
await future.timeout(timeout);
} on TimeoutException {
_log.w('Timed out waiting for extension initialization after $timeout');
}
}
@@ -618,7 +566,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
final list = await PlatformBridge.getInstalledExtensions();
final extensions = list.map((e) => Extension.fromJson(e)).toList();
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
_log.d('Loaded ${extensions.length} extensions');
for (final ext in extensions) {
@@ -714,7 +661,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}).toList();
state = state.copyWith(extensions: extensions);
await _reconcileDownloadProviderPriority();
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
@@ -739,23 +685,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<void> _reconcileDownloadProviderPriority() async {
if (state.providerPriority.isEmpty) {
return;
}
final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority);
if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) {
return;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
await PlatformBridge.setProviderPriority(sanitized);
state = state.copyWith(providerPriority: sanitized);
_log.d('Reconciled provider priority after extension update: $sanitized');
}
Future<bool> ensureSpotifyWebExtensionReady({
bool setAsSearchProvider = true,
}) async {
@@ -883,7 +812,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> _sanitizeDownloadProviderPriority(List<String> input) {
final allowed = getAllDownloadProviders().toSet();
final preferredOrder = getAllDownloadProviders();
final result = <String>[];
for (final provider in input) {
@@ -892,7 +820,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
for (final provider in preferredOrder) {
for (final provider in const ['tidal', 'qobuz']) {
if (!result.contains(provider)) {
result.add(provider);
}
@@ -919,15 +847,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
);
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
final backendPriority =
await PlatformBridge.getMetadataProviderPriority();
priority = _sanitizeMetadataProviderPriority(backendPriority);
_log.d('Using default metadata provider priority: $priority');
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
priority = _sanitizeMetadataProviderPriority(
await PlatformBridge.getMetadataProviderPriority(),
);
await PlatformBridge.setMetadataProviderPriority(priority);
_log.d('Using default metadata provider priority: $priority');
}
state = state.copyWith(metadataProviderPriority: priority);
@@ -983,26 +906,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllMetadataProviders() {
final metadataExtensions = state.extensions
.where((ext) => ext.enabled && ext.hasMetadataProvider)
.toList();
final primarySearchMetadataExtensions = metadataExtensions
.where((ext) => ext.searchBehavior?.primary == true)
.map((ext) => ext.id);
final otherMetadataExtensions = metadataExtensions
.where((ext) => ext.searchBehavior?.primary != true)
.map((ext) => ext.id);
return [
...primarySearchMetadataExtensions,
..._builtInMetadataProviders,
...otherMetadataExtensions,
];
final providers = ['deezer', 'qobuz', 'tidal'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
}
}
return providers;
}
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
final allowed = getAllMetadataProviders().toSet();
final preferredOrder = getAllMetadataProviders();
final result = <String>[];
for (final provider in input) {
@@ -1011,18 +925,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
final hasPreferredExtension = preferredOrder.any(
(provider) => !_builtInMetadataProviders.contains(provider),
);
final hasSavedExtension = result.any(
(provider) => !_builtInMetadataProviders.contains(provider),
);
if (!hasSavedExtension && hasPreferredExtension) {
return List<String>.from(preferredOrder);
}
for (final provider in preferredOrder) {
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
if (!result.contains(provider)) {
result.add(provider);
}
-16
View File
@@ -18,7 +18,6 @@ final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
static const Set<String> _searchTabValues = {'all', 'track', 'artist', 'album'};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -43,15 +42,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
state = loaded.copyWith(
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
);
await _runMigrations(prefs);
@@ -192,12 +187,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'US';
}
String _normalizeDefaultSearchTab(String value) {
final normalized = value.trim().toLowerCase();
if (_searchTabValues.contains(normalized)) return normalized;
return 'all';
}
Future<void> _normalizeSongLinkRegionIfNeeded() async {
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
if (normalized == state.songLinkRegion) return;
@@ -419,11 +408,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDefaultSearchTab(String tab) {
state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab));
_saveSettings();
}
void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true);
+4
View File
@@ -63,6 +63,7 @@ class StoreExtension {
final String name;
final String displayName;
final String version;
final String author;
final String description;
final String downloadUrl;
final String? iconUrl;
@@ -80,6 +81,7 @@ class StoreExtension {
required this.name,
required this.displayName,
required this.version,
required this.author,
required this.description,
required this.downloadUrl,
this.iconUrl,
@@ -100,6 +102,7 @@ class StoreExtension {
displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
downloadUrl: json['download_url'] as String? ?? '',
iconUrl: json['icon_url'] as String?,
@@ -191,6 +194,7 @@ class StoreState {
e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)),
)
.toList();
+36 -139
View File
@@ -7,7 +7,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('TrackProvider');
const _extensionInitRetryTimeout = Duration(seconds: 30);
class TrackState {
final List<Track> tracks;
@@ -204,36 +203,13 @@ class TrackNotifier extends Notifier<TrackState> {
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
bool _usesBuiltInUrlResolver(String url) {
final normalized = url.toLowerCase();
return normalized.contains('deezer.com') ||
normalized.contains('deezer.page.link') ||
normalized.contains('qobuz.com') ||
normalized.startsWith('qobuzapp://') ||
normalized.contains('tidal.com');
}
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
final requestId = ++_currentRequestId;
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
var extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler == null && !_usesBuiltInUrlResolver(url)) {
final extensionState = ref.read(extensionProvider);
if (!extensionState.isInitialized && extensionState.isLoading) {
_log.i(
'Extension URL handlers not ready yet, waiting for initialization...',
);
await ref
.read(extensionProvider.notifier)
.waitForInitialization(timeout: _extensionInitRetryTimeout);
if (!_isRequestValid(requestId)) return;
extensionHandler = await PlatformBridge.findURLHandler(url);
}
}
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -583,99 +559,8 @@ class TrackNotifier extends Notifier<TrackState> {
String? builtInSearchProvider,
}) async {
final requestId = ++_currentRequestId;
final currentFilter = filterOverride ?? state.selectedSearchFilter;
final requestFilter = currentFilter == 'all' ? null : currentFilter;
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
String? resolvedProvider = builtInSearchProvider;
if (resolvedProvider == null || resolvedProvider.isEmpty) {
final explicitProvider = settings.searchProvider?.trim();
if (explicitProvider != null && explicitProvider.isNotEmpty) {
resolvedProvider = explicitProvider;
} else {
resolvedProvider =
extensionState.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.map((ext) => ext.id)
.firstOrNull ??
extensionState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.map((ext) => ext.id)
.firstOrNull;
}
resolvedProvider ??= 'tidal';
}
final isEnabledExtensionProvider =
resolvedProvider.isNotEmpty &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
);
if (resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
!isEnabledExtensionProvider &&
settings.searchProvider?.trim() == resolvedProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
resolvedProvider =
extensionState.extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.map((ext) => ext.id)
.firstOrNull ??
extensionState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.map((ext) => ext.id)
.firstOrNull;
resolvedProvider ??= 'tidal';
}
if (resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
)) {
final resolvedFilter = requestFilter ?? 'track';
Map<String, dynamic>? options;
options = {'filter': resolvedFilter};
await customSearch(
resolvedProvider,
query,
options: options,
selectedFilter: resolvedFilter,
);
return;
}
final effectiveBuiltInProvider =
resolvedProvider == 'tidal' || resolvedProvider == 'qobuz'
? resolvedProvider
: (builtInSearchProvider?.isNotEmpty == true
? builtInSearchProvider
: 'tidal');
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
state = TrackState(
isLoading: false,
error: 'No active search provider available',
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
);
return;
}
state = TrackState(
isLoading: true,
@@ -685,21 +570,42 @@ class TrackNotifier extends Notifier<TrackState> {
);
try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final hasActiveMetadataExtensions = extensionState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider,
);
final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions;
final effectiveProvider = effectiveBuiltInProvider;
final effectiveProvider = builtInSearchProvider ?? 'deezer';
_log.i(
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
);
Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = [];
if (effectiveProvider == 'deezer') {
try {
_log.d('Calling metadata provider search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
_log.i(
'Metadata providers returned ${metadataTrackResults.length} tracks',
);
} catch (e) {
_log.w(
'Metadata provider search failed, falling back to Deezer tracks: $e',
);
}
}
switch (effectiveProvider) {
case 'tidal':
_log.d('Calling Tidal search API...');
@@ -707,7 +613,7 @@ class TrackNotifier extends Notifier<TrackState> {
query,
trackLimit: 20,
artistLimit: 2,
filter: requestFilter,
filter: currentFilter,
);
break;
case 'qobuz':
@@ -716,23 +622,17 @@ class TrackNotifier extends Notifier<TrackState> {
query,
trackLimit: 20,
artistLimit: 2,
filter: requestFilter,
filter: currentFilter,
);
break;
default:
_log.d('Calling metadata provider track search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
results = const <String, List<dynamic>>{
'tracks': <dynamic>[],
'artists': <dynamic>[],
'albums': <dynamic>[],
'playlists': <dynamic>[],
};
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
}
_log.i(
@@ -841,16 +741,14 @@ class TrackNotifier extends Notifier<TrackState> {
String extensionId,
String query, {
Map<String, dynamic>? options,
String? selectedFilter,
}) async {
final requestId = ++_currentRequestId;
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
selectedSearchFilter: state.selectedSearchFilter,
);
try {
@@ -890,7 +788,7 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId,
selectedSearchFilter: currentFilter,
selectedSearchFilter: state.selectedSearchFilter,
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -900,7 +798,6 @@ class TrackNotifier extends Notifier<TrackState> {
error: e.toString(),
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
);
}
}
+34 -288
View File
@@ -426,18 +426,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
String? currentSearchProvider,
List<Extension> extensions,
) {
final resolvedSearchProvider = _resolveSearchProvider(
currentSearchProvider,
extensions,
);
final isUsingExtensionSearch =
resolvedSearchProvider != null &&
resolvedSearchProvider.isNotEmpty &&
extensions.any((e) => e.id == resolvedSearchProvider && e.enabled);
currentSearchProvider != null &&
currentSearchProvider.isNotEmpty &&
extensions.any((e) => e.id == currentSearchProvider && e.enabled);
if (isUsingExtensionSearch) {
final currentSearchExtension = extensions
.where((e) => e.id == resolvedSearchProvider && e.enabled)
.where((e) => e.id == currentSearchProvider && e.enabled)
.firstOrNull;
final filters = currentSearchExtension?.searchBehavior?.filters;
if (filters != null && filters.isNotEmpty) {
@@ -453,153 +449,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
];
}
Extension? _defaultSearchExtension(List<Extension> extensions) {
return extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.firstOrNull ??
extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.firstOrNull;
}
String? _resolveSearchProvider(
String? explicitSearchProvider,
List<Extension> extensions,
) {
final explicit = explicitSearchProvider?.trim();
if (explicit != null &&
explicit.isNotEmpty &&
(_builtInSearchProviders.contains(explicit) ||
extensions.any(
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
))) {
return explicit;
}
return _defaultSearchExtension(extensions)?.id ?? 'tidal';
}
String? _sanitizeSearchFilterForProvider(
String? filter,
String? currentSearchProvider,
List<Extension> extensions,
) {
if (filter == null || filter.isEmpty) {
return null;
}
final canonicalFilter = _canonicalSearchFilterId(filter);
if (currentSearchProvider == null ||
currentSearchProvider.isEmpty ||
_builtInSearchProviders.contains(currentSearchProvider)) {
switch (canonicalFilter) {
case 'track':
case 'artist':
case 'album':
case 'playlist':
return canonicalFilter;
default:
return null;
}
}
final extension = extensions
.where((e) => e.id == currentSearchProvider && e.enabled)
.firstOrNull;
final filters = extension?.searchBehavior?.filters;
if (filters == null || filters.isEmpty) {
return null;
}
final match = filters
.where(
(candidate) =>
_canonicalSearchFilterId(candidate.id) == canonicalFilter ||
(candidate.label != null &&
_canonicalSearchFilterId(candidate.label!) ==
canonicalFilter) ||
(candidate.icon != null &&
_canonicalSearchFilterId(candidate.icon!) == canonicalFilter),
)
.firstOrNull;
return match?.id;
}
String _canonicalSearchFilterId(String value) {
final normalized = value.trim().toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]+'),
'',
);
switch (normalized) {
case 'track':
case 'tracks':
case 'song':
case 'songs':
case 'music':
return 'track';
case 'artist':
case 'artists':
return 'artist';
case 'album':
case 'albums':
return 'album';
case 'playlist':
case 'playlists':
return 'playlist';
default:
return normalized;
}
}
String? _preferredSearchFilter(
String preferredSearchTab,
String? currentSearchProvider,
List<Extension> extensions,
) {
final preferred = switch (preferredSearchTab) {
'track' => 'track',
'artist' => 'artist',
'album' => 'album',
_ => null,
};
return _sanitizeSearchFilterForProvider(
preferred,
currentSearchProvider,
extensions,
);
}
String _displaySearchFilterSelection(
String? selectedSearchFilter,
String preferredSearchTab,
String? currentSearchProvider,
List<Extension> extensions,
) {
if (selectedSearchFilter == 'all') {
return 'all';
}
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
return _sanitizeSearchFilterForProvider(
selectedSearchFilter,
currentSearchProvider,
extensions,
) ??
'all';
}
return _preferredSearchFilter(
preferredSearchTab,
currentSearchProvider,
extensions,
) ??
'all';
}
_SearchResultBuckets _getSearchResultBuckets(List<Track> tracks) {
final cached = _searchBucketsCache;
if (cached != null && identical(tracks, _searchBucketsSourceTracks)) {
@@ -681,10 +530,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
bool _isLiveSearchEnabled() {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
final searchProvider = settings.searchProvider;
if (searchProvider == null || searchProvider.isEmpty) return false;
@@ -753,32 +599,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
Future<void> _performSearch(String query, {String? filterOverride}) async {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
final storedFilter = ref.read(trackProvider).selectedSearchFilter;
final selectedFilter = switch (filterOverride) {
'all' => null,
final explicit? => _sanitizeSearchFilterForProvider(
explicit,
searchProvider,
extState.extensions,
),
null => switch (storedFilter) {
'all' => null,
final stored? => _sanitizeSearchFilterForProvider(
stored,
searchProvider,
extState.extensions,
),
null => _preferredSearchFilter(
settings.defaultSearchTab,
searchProvider,
extState.extensions,
),
},
};
final searchProvider = settings.searchProvider;
final selectedFilter =
filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
final searchKey =
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
@@ -804,12 +627,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
await ref
.read(trackProvider.notifier)
.customSearch(
searchProvider,
query,
options: options,
selectedFilter: selectedFilter,
);
.customSearch(searchProvider, query, options: options);
} else if (isBuiltInProvider) {
await ref
.read(trackProvider.notifier)
@@ -1244,9 +1062,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
final hasSearchedBefore = ref.watch(
settingsProvider.select((s) => s.hasSearchedBefore),
);
final defaultSearchTab = ref.watch(
settingsProvider.select((s) => s.defaultSearchTab),
);
final hasExploreContent = ref.watch(
exploreProvider.select((s) => s.sections.isNotEmpty),
@@ -1288,29 +1103,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
(hasHomeFeedExtension || hasExploreContent) &&
hasExploreContent;
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
previous,
next,
) {
if (previous == next) return;
final selectedSearchFilter = ref.read(
trackProvider.select((s) => s.selectedSearchFilter),
);
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
return;
}
final text = _urlController.text.trim();
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_lastSearchQuery = null;
_performSearch(text);
});
});
if (hasActualResults &&
isShowingRecentAccess &&
hasSearchInput &&
@@ -1454,12 +1246,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return SliverToBoxAdapter(
child: _buildSearchFilterBar(
searchFilters,
_displaySearchFilterSelection(
selectedSearchFilter,
defaultSearchTab,
currentSearchProvider,
extensions,
),
selectedSearchFilter,
colorScheme,
),
);
@@ -2305,25 +2092,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
bool _isEnabledMetadataExtension(String? providerId) {
final normalized = providerId?.trim();
if (normalized == null || normalized.isEmpty) return false;
return ref
.read(extensionProvider)
.extensions
.any(
(ext) =>
ext.enabled && ext.hasMetadataProvider && ext.id == normalized,
);
}
void _navigateToRecentItem(RecentAccessItem item) {
_searchFocusNode.unfocus();
switch (item.type) {
case RecentAccessType.artist:
if (_isEnabledMetadataExtension(item.providerId)) {
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute<void>(
@@ -2360,7 +2139,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
);
} else if (_isEnabledMetadataExtension(item.providerId)) {
} else if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute<void>(
@@ -2405,7 +2189,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
return;
}
if (_isEnabledMetadataExtension(item.providerId)) {
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute<void>(
@@ -3322,11 +3111,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
String _getSearchHint() {
final settings = ref.read(settingsProvider);
final searchProvider = settings.searchProvider;
final extState = ref.read(extensionProvider);
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
);
if (!extState.isInitialized) {
return 'Paste supported URL or search...';
@@ -3368,10 +3154,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(context.l10n.historyFilterAll),
selected: selectedFilter == 'all',
selected: selectedFilter == null,
onSelected: (_) {
ref.read(trackProvider.notifier).setSearchFilter('all');
_triggerSearchWithFilter('all');
ref.read(trackProvider.notifier).setSearchFilter(null);
_triggerSearchWithFilter(null);
},
showCheckmark: false,
),
@@ -3527,23 +3313,9 @@ class _SearchProviderDropdown extends ConsumerWidget {
const _SearchProviderDropdown({this.onProviderChanged});
Extension? _defaultSearchExtension(List<Extension> extensions) {
return extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.firstOrNull ??
extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.firstOrNull;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final rawCurrentProvider = ref.watch(
final currentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
@@ -3552,19 +3324,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
final searchProviders = extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList();
final primarySearchExtension = _defaultSearchExtension(searchProviders);
final defaultProviderTarget =
primarySearchExtension?.displayName ?? 'Tidal';
final defaultProviderLabel =
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
final defaultProviderIconPath = primarySearchExtension?.iconPath;
final currentProvider =
rawCurrentProvider != null &&
rawCurrentProvider.isNotEmpty &&
({'tidal', 'qobuz'}.contains(rawCurrentProvider) ||
searchProviders.any((e) => e.id == rawCurrentProvider))
? rawCurrentProvider
: null;
Extension? currentExt;
if (currentProvider != null && currentProvider.isNotEmpty) {
@@ -3584,19 +3343,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
if (currentExt.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
}
} else if (primarySearchExtension?.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(
primarySearchExtension!.searchBehavior!.icon!,
);
iconPath = defaultProviderIconPath;
} else if (defaultProviderIconPath != null &&
defaultProviderIconPath.isNotEmpty) {
iconPath = defaultProviderIconPath;
if (primarySearchExtension?.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(
primarySearchExtension!.searchBehavior!.icon!,
);
}
} else if (isBuiltInProvider) {
displayIcon = Icons.music_note;
}
@@ -3651,7 +3397,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
defaultProviderLabel,
'Deezer',
style: TextStyle(
fontWeight:
currentProvider == null || currentProvider.isEmpty
+15
View File
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
@@ -93,6 +94,20 @@ class _MainShellState extends ConsumerState<MainShell>
}
Future<void> _handleSharedUrl(String url) async {
// Wait for extensions to be initialized before handling URL
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...');
for (int i = 0; i < 50; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
if (!mounted) return;
if (ref.read(extensionProvider).isInitialized) {
_log.d('Extensions initialized, proceeding with URL handling');
break;
}
}
}
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
-34
View File
@@ -4333,40 +4333,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
if (filterMode == 'all' &&
totalTrackCount == 0 &&
!showFilteringIndicator &&
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
const Spacer(),
if (!_isSelectionMode)
_buildFilterButton(context, unifiedItems),
],
),
),
),
if (filterMode == 'singles' &&
totalTrackCount == 0 &&
!showFilteringIndicator &&
(_activeFilterCount > 0 || unifiedItems.isNotEmpty))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
const Spacer(),
if (!_isSelectionMode)
_buildFilterButton(context, unifiedItems),
],
),
),
),
if (historyItems.isNotEmpty && hasQueueItems)
SliverToBoxAdapter(
child: Padding(
+7
View File
@@ -789,6 +789,13 @@ class _ExtensionItem extends StatelessWidget {
),
],
),
const SizedBox(height: 2),
Text(
'by ${extension.author}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (extension.requiresNewerApp) ...[
const SizedBox(height: 4),
Container(
-7
View File
@@ -182,13 +182,6 @@ class AboutPage extends StatelessWidget {
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.campaign_outlined,
title: context.l10n.aboutKeepAndroidOpen,
subtitle: 'keepandroidopen.org',
onTap: () => _launchUrl('https://keepandroidopen.org/'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: context.l10n.aboutReportIssue,
@@ -735,7 +735,6 @@ class _LanguageSelector extends StatelessWidget {
('pt_PT', 'Português (Brasil)', Icons.language),
('ru', 'Русский', Icons.language),
('tr', 'Türkçe', Icons.language),
('uk', 'Українська', Icons.language),
('zh', '简体中文', Icons.language),
('zh_CN', '简体中文 (中国)', Icons.language),
('zh_TW', '繁體中文', Icons.language),
+1 -9
View File
@@ -164,15 +164,7 @@ class _RecentDonorsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>[
'Ldav',
'Nico',
'Feuerstern',
'R4ND0MIZ3D',
'Isra',
'bigJr48',
'Mick',
];
const donorNames = <String>['R4ND0MIZ3D', 'Isra', 'bigJr48'];
// Match SettingsGroup color logic
final cardColor = isDark
+14 -105
View File
@@ -8,7 +8,6 @@ import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:url_launcher/url_launcher.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget {
final String extensionId;
@@ -50,6 +49,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
name: '',
displayName: 'Unknown',
version: '0.0.0',
author: 'Unknown',
description: '',
enabled: false,
status: 'error',
@@ -205,6 +205,10 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
],
const SizedBox(height: 16),
_InfoRow(
label: context.l10n.extensionAuthor,
value: extension.author,
),
_InfoRow(
label: context.l10n.extensionId,
value: extension.id,
@@ -400,7 +404,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
onChanged: (value) =>
_updateSetting(setting.key, value),
extensionId: widget.extensionId,
onActionPayload: _handleExtensionActionPayload,
);
}).toList(),
),
@@ -442,27 +445,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
.setExtensionSettings(widget.extensionId, _settings);
}
/// Extensions may return `setting_updates` from button actions (e.g. OAuth URL field).
Future<void> _handleExtensionActionPayload(
Map<String, dynamic> payload,
) async {
final raw = payload['setting_updates'];
if (raw is! Map) return;
final partial = <String, dynamic>{};
for (final entry in raw.entries) {
partial[entry.key.toString()] = entry.value;
}
if (partial.isEmpty) return;
final merged = Map<String, dynamic>.from(_settings);
merged.addAll(partial);
await ref
.read(extensionProvider.notifier)
.setExtensionSettings(widget.extensionId, merged);
if (mounted) {
setState(() => _settings = merged);
}
}
Future<void> _confirmRemove(BuildContext context) async {
final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
@@ -496,41 +478,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
}
}
/// Long OAuth URLs: selectable text so users can copy without relying on snackbars.
class _OauthLoginLinkPreview extends StatelessWidget {
final String? value;
final ColorScheme colorScheme;
const _OauthLoginLinkPreview({
required this.value,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return Text(
'Tap Connect to Spotify to fill this field.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
);
}
return SelectionArea(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontFamily: 'monospace',
fontSize: 11,
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
@@ -698,14 +645,12 @@ class _SettingItem extends StatefulWidget {
final bool showDivider;
final ValueChanged<dynamic> onChanged;
final String extensionId;
final Future<void> Function(Map<String, dynamic> payload)? onActionPayload;
const _SettingItem({
required this.setting,
required this.value,
required this.onChanged,
required this.extensionId,
this.onActionPayload,
this.showDivider = true,
});
@@ -827,17 +772,11 @@ class _SettingItemState extends State<_SettingItem> {
if (widget.setting.type == 'string' ||
widget.setting.type == 'number') ...[
const SizedBox(height: 4),
if (widget.setting.key == 'oauth_login_url')
_OauthLoginLinkPreview(
value: widget.value?.toString(),
colorScheme: colorScheme,
)
else
Text(
widget.value?.toString() ?? 'Not set',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
Text(
widget.value?.toString() ?? 'Not set',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
],
],
),
@@ -876,45 +815,15 @@ class _SettingItemState extends State<_SettingItem> {
);
if (context.mounted) {
// Go may return either a flat map or { success, result: { ... } }.
Map<String, dynamic> payload = result;
final nested = result['result'];
if (nested is Map) {
payload = Map<String, dynamic>.from(nested);
}
final success = payload['success'] as bool? ?? false;
final success = result['success'] as bool? ?? false;
if (!success) {
final error =
payload['error'] as String? ??
result['error'] as String? ??
'Action failed';
final error = result['error'] as String? ?? 'Action failed';
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
} else {
if (widget.onActionPayload != null) {
await widget.onActionPayload!(payload);
}
final openAuth = payload['open_auth_url'] as String?;
if (openAuth != null && openAuth.isNotEmpty) {
final uri = Uri.parse(openAuth);
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarError('Could not open browser'),
),
),
);
}
}
final message = payload['message'] as String?;
if (message != null && message.isNotEmpty && context.mounted) {
final message = result['message'] as String?;
if (message != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
+1 -1
View File
@@ -425,7 +425,7 @@ class _ExtensionItem extends StatelessWidget {
hasError
? extension.errorMessage ??
context.l10n.extensionsErrorLoading
: 'v${extension.version}',
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
? colorScheme.error
@@ -225,8 +225,8 @@ class _MetadataProviderItem extends StatelessWidget {
return _MetadataProviderInfo(
name: 'Deezer',
icon: Icons.album,
description: context.l10n.providerExtension,
isBuiltIn: false,
description: context.l10n.metadataNoRateLimits,
isBuiltIn: true,
);
case 'qobuz':
return _MetadataProviderInfo(
+58 -178
View File
@@ -70,12 +70,7 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: const [
_MetadataSourceSelector(),
_DefaultSearchTabSelector(),
],
),
child: SettingsGroup(children: [const _MetadataSourceSelector()]),
),
SliverToBoxAdapter(
@@ -719,41 +714,13 @@ class _MetadataSourceSelector extends ConsumerWidget {
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
Extension? _defaultSearchExtension(List<Extension> extensions) {
return extensions
.where(
(ext) =>
ext.enabled &&
ext.hasCustomSearch &&
ext.searchBehavior?.primary == true,
)
.firstOrNull ??
extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.firstOrNull;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final rawSearchProvider = settings.searchProvider?.trim() ?? '';
final isValidBuiltIn = _builtInProviders.containsKey(rawSearchProvider);
final primarySearchExtension = _defaultSearchExtension(extState.extensions);
final defaultProviderTarget =
primarySearchExtension?.displayName ?? 'Tidal';
final defaultProviderLabel =
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
final searchProvider =
isValidBuiltIn ||
extState.extensions.any(
(e) =>
e.enabled && e.hasCustomSearch && e.id == rawSearchProvider,
)
? rawSearchProvider
: '';
final searchProvider = settings.searchProvider ?? '';
final isBuiltIn = _builtInProviders.containsKey(searchProvider);
Extension? activeExtension;
@@ -798,45 +765,37 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _SourceChip(
icon: Icons.graphic_eq,
label: defaultProviderLabel,
isSelected: searchProvider.isEmpty,
onTap: () {
if (hasNonDefaultProvider) {
ref
.read(settingsProvider.notifier)
.setSearchProvider(null);
}
},
),
_SourceChip(
icon: Icons.graphic_eq,
label: 'Deezer',
isSelected: searchProvider.isEmpty,
onTap: () {
if (hasNonDefaultProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
},
),
const SizedBox(width: 8),
Expanded(
child: _SourceChip(
icon: Icons.waves,
label: 'Tidal',
isSelected: searchProvider == 'tidal',
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider('tidal');
},
),
_SourceChip(
icon: Icons.waves,
label: 'Tidal',
isSelected: searchProvider == 'tidal',
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider('tidal');
},
),
const SizedBox(width: 8),
Expanded(
child: _SourceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: searchProvider == 'qobuz',
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider('qobuz');
},
),
_SourceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: searchProvider == 'qobuz',
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider('qobuz');
},
),
],
),
@@ -852,7 +811,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Tap $defaultProviderLabel to switch back from extension',
'Tap Deezer to switch back from extension',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -867,88 +826,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
}
}
class _DefaultSearchTabSelector extends ConsumerWidget {
const _DefaultSearchTabSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final selectedTab = ref.watch(
settingsProvider.select((s) => s.defaultSearchTab),
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.optionsDefaultSearchTab,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
context.l10n.optionsDefaultSearchTabSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _SourceChip(
icon: Icons.dashboard_outlined,
label: context.l10n.historyFilterAll,
isSelected: selectedTab == 'all',
onTap: () => ref
.read(settingsProvider.notifier)
.setDefaultSearchTab('all'),
),
),
const SizedBox(width: 8),
Expanded(
child: _SourceChip(
icon: Icons.music_note,
label: context.l10n.searchSongs,
isSelected: selectedTab == 'track',
onTap: () => ref
.read(settingsProvider.notifier)
.setDefaultSearchTab('track'),
),
),
const SizedBox(width: 8),
Expanded(
child: _SourceChip(
icon: Icons.person,
label: context.l10n.searchArtists,
isSelected: selectedTab == 'artist',
onTap: () => ref
.read(settingsProvider.notifier)
.setDefaultSearchTab('artist'),
),
),
const SizedBox(width: 8),
Expanded(
child: _SourceChip(
icon: Icons.album,
label: context.l10n.searchAlbums,
isSelected: selectedTab == 'album',
onTap: () => ref
.read(settingsProvider.notifier)
.setDefaultSearchTab('album'),
),
),
],
),
],
),
);
}
}
class _SourceChip extends StatelessWidget {
final IconData icon;
final String label;
@@ -974,36 +851,39 @@ class _SourceChip extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
return Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 28,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
size: 28,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
+1 -18
View File
@@ -10,9 +10,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('SetupScreen');
class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key});
@@ -236,21 +233,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (Platform.isIOS) {
await _showIOSDirectoryOptions();
} else if (Platform.isAndroid) {
Map<String, dynamic>? result;
try {
result = await PlatformBridge.pickSafTree();
} catch (e) {
_log.w('Failed to open Android SAF picker: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarCannotOpenFile(e.toString()),
),
),
);
}
}
final result = await PlatformBridge.pickSafTree();
if (result != null) {
final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? '';
@@ -171,6 +171,12 @@ class _ExtensionDetailsScreenState
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
context.l10n.extensionsAuthor(ext.author),
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
+40 -123
View File
@@ -4270,12 +4270,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'copyright': val('copyright', copyright),
'composer': val('composer', composer),
'comment': fileMetadata?['comment']?.toString() ?? '',
'lyrics': fileMetadata?['lyrics']?.toString() ?? '',
};
final initialDurationSeconds =
_readPositiveInt(fileMetadata?['duration']) ?? duration ?? 0;
if (!context.mounted) return;
final saved = await showModalBottomSheet<bool>(
@@ -4291,9 +4287,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
initialValues: initialValues,
filePath: cleanFilePath,
sourceTrackId: _spotifyId,
durationMs: initialDurationSeconds > 0
? initialDurationSeconds * 1000
: 0,
artistTagMode: ref.read(settingsProvider).artistTagMode,
),
);
@@ -4304,24 +4297,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
final refreshedLyrics = refreshed['lyrics']?.toString().trim() ?? '';
setState(() {
_editedMetadata = refreshed;
_lyricsError = null;
_isInstrumental = false;
_embeddedLyricsChecked = true;
if (refreshedLyrics.isNotEmpty) {
_lyrics = _cleanLrcForDisplay(refreshedLyrics);
_rawLyrics = refreshedLyrics;
_lyricsSource = 'Embedded';
_lyricsEmbedded = true;
} else {
_lyrics = null;
_rawLyrics = null;
_lyricsSource = null;
_lyricsEmbedded = false;
}
});
setState(() => _editedMetadata = refreshed);
} catch (_) {
setState(() {});
}
@@ -4538,7 +4514,6 @@ class _EditMetadataSheet extends StatefulWidget {
final Map<String, String> initialValues;
final String filePath;
final String? sourceTrackId;
final int durationMs;
final String artistTagMode;
const _EditMetadataSheet({
@@ -4546,7 +4521,6 @@ class _EditMetadataSheet extends StatefulWidget {
required this.initialValues,
required this.filePath,
this.sourceTrackId,
required this.durationMs,
required this.artistTagMode,
});
@@ -4586,7 +4560,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'total_discs': 'total_discs',
'genre': 'genre',
'isrc': 'isrc',
'lyrics': 'lyrics',
'label': 'label',
'copyright': 'copyright',
'composer': 'composer',
@@ -4604,7 +4577,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
late final TextEditingController _discTotalCtrl;
late final TextEditingController _genreCtrl;
late final TextEditingController _isrcCtrl;
late final TextEditingController _lyricsCtrl;
late final TextEditingController _labelCtrl;
late final TextEditingController _copyrightCtrl;
late final TextEditingController _composerCtrl;
@@ -4800,8 +4772,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return l10n.editMetadataFieldGenre;
case 'isrc':
return l10n.editMetadataFieldIsrc;
case 'lyrics':
return l10n.trackLyrics;
case 'label':
return l10n.editMetadataFieldLabel;
case 'copyright':
@@ -4839,8 +4809,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return _genreCtrl;
case 'isrc':
return _isrcCtrl;
case 'lyrics':
return _lyricsCtrl;
case 'label':
return _labelCtrl;
case 'copyright':
@@ -5139,23 +5107,19 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final artist = _artistCtrl.text.trim();
final album = _albumCtrl.text.trim();
final currentIsrc = _isrcCtrl.text.trim().toUpperCase();
final shouldFetchLyrics = _autoFillFields.contains('lyrics');
final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics');
Map<String, dynamic>? best;
String? deezerId;
if (needsTrackLookup) {
try {
final resolved = await _resolveAutoFillTrackFromIdentifiers(
currentIsrc,
);
if (resolved != null) {
best = resolved.track;
deezerId = resolved.deezerId;
}
} catch (e) {
_log.w('Identifier-first autofill lookup failed: $e');
try {
final resolved = await _resolveAutoFillTrackFromIdentifiers(
currentIsrc,
);
if (resolved != null) {
best = resolved.track;
deezerId = resolved.deezerId;
}
} catch (e) {
_log.w('Identifier-first autofill lookup failed: $e');
}
final queryParts = <String>[];
@@ -5163,7 +5127,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (artist.isNotEmpty) queryParts.add(artist);
if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album);
if (needsTrackLookup && best == null && queryParts.isEmpty) {
if (best == null && queryParts.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
@@ -5176,7 +5140,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final normalizedArtist = _normalizeMetadataText(artist);
final normalizedAlbum = _normalizeMetadataText(album);
if (needsTrackLookup && best == null) {
if (best == null) {
final query = queryParts.join(' ');
final results = await PlatformBridge.searchTracksWithMetadataProviders(
query,
@@ -5211,47 +5175,39 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
}
final selectedBest = best;
if (needsTrackLookup && selectedBest == null) {
if (selectedBest == null) {
throw StateError('No metadata match resolved for auto-fill');
}
final enriched = <String, String>{};
if (selectedBest != null) {
enriched.addAll(<String, String>{
'title': (selectedBest['name'] ?? '').toString(),
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
.toString(),
'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '')
.toString(),
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
'date': (selectedBest['release_date'] ?? '').toString(),
'track_number': (selectedBest['track_number'] ?? '').toString(),
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
'total_discs': (selectedBest['total_discs'] ?? '').toString(),
'isrc': (selectedBest['isrc'] ?? '').toString(),
'composer': (selectedBest['composer'] ?? '').toString(),
});
_mergeOnlineTrackData(enriched, selectedBest);
}
final enriched = <String, String>{
'title': (selectedBest['name'] ?? '').toString(),
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
.toString(),
'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '')
.toString(),
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
'date': (selectedBest['release_date'] ?? '').toString(),
'track_number': (selectedBest['track_number'] ?? '').toString(),
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
'total_discs': (selectedBest['total_discs'] ?? '').toString(),
'isrc': (selectedBest['isrc'] ?? '').toString(),
'composer': (selectedBest['composer'] ?? '').toString(),
};
_mergeOnlineTrackData(enriched, selectedBest);
final enrichedIsrc = (enriched['isrc'] ?? '').trim();
final needsIsrc =
_autoFillFields.contains('isrc') && enrichedIsrc.isEmpty;
_autoFillFields.contains('isrc') && enriched['isrc']!.isEmpty;
final needsExtended =
_autoFillFields.contains('genre') ||
_autoFillFields.contains('label') ||
_autoFillFields.contains('copyright') ||
_autoFillFields.contains('composer');
final rawSpotifyId = selectedBest == null
? _extractRawSpotifyTrackIdFromValue(widget.sourceTrackId)
: _extractRawSpotifyTrackId(selectedBest);
final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest);
deezerId ??= selectedBest == null
? null
: _extractRawDeezerTrackId(selectedBest);
final candidateIsrc = enrichedIsrc.toUpperCase();
deezerId ??= _extractRawDeezerTrackId(selectedBest);
final candidateIsrc = enriched['isrc']!.trim().toUpperCase();
final deezerLookupIsrc = _looksLikeIsrc(currentIsrc)
? currentIsrc
: (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : '');
@@ -5287,9 +5243,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return;
// Fetch ISRC from Deezer track metadata if still missing
if (needsIsrc &&
(enriched['isrc'] ?? '').trim().isEmpty &&
deezerId != null) {
if (needsIsrc && enriched['isrc']!.isEmpty && deezerId != null) {
try {
final deezerMeta = await PlatformBridge.getDeezerMetadata(
'track',
@@ -5321,37 +5275,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
}
}
if (shouldFetchLyrics) {
final lyricsTitle =
((selectedBest?['name'] ?? selectedBest?['title'] ?? title)
.toString())
.trim();
final lyricsArtist =
((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist)
.toString())
.trim();
if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) {
try {
final lyricsResult = await PlatformBridge.getLyricsLRCWithSource(
rawSpotifyId ?? '',
lyricsTitle,
lyricsArtist,
durationMs: widget.durationMs,
);
final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? '';
final instrumental =
(lyricsResult['instrumental'] as bool? ?? false) ||
lyricsText == '[instrumental:true]';
if (!instrumental && lyricsText.isNotEmpty) {
enriched['lyrics'] = lyricsText;
}
} catch (e) {
_log.w('Lyrics autofill failed: $e');
}
}
}
if (!mounted) return;
var filledCount = 0;
@@ -5370,7 +5293,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
}
}
if (_autoFillFields.contains('cover') && selectedBest != null) {
if (_autoFillFields.contains('cover')) {
final coverUrl =
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
.toString();
@@ -5446,7 +5369,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
_discTotalCtrl = TextEditingController(text: v['total_discs'] ?? '');
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
_lyricsCtrl = TextEditingController(text: v['lyrics'] ?? '');
_labelCtrl = TextEditingController(text: v['label'] ?? '');
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
@@ -5469,7 +5391,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
_discTotalCtrl.dispose();
_genreCtrl.dispose();
_isrcCtrl.dispose();
_lyricsCtrl.dispose();
_labelCtrl.dispose();
_copyrightCtrl.dispose();
_composerCtrl.dispose();
@@ -5492,7 +5413,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'disc_total': _discTotalCtrl.text,
'genre': _genreCtrl.text,
'isrc': _isrcCtrl.text,
'lyrics': _lyricsCtrl.text,
'label': _labelCtrl.text,
'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text,
@@ -5557,8 +5477,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
: '',
'GENRE': metadata['genre'] ?? '',
'ISRC': metadata['isrc'] ?? '',
'LYRICS': metadata['lyrics'] ?? '',
'UNSYNCEDLYRICS': metadata['lyrics'] ?? '',
'ORGANIZATION': metadata['label'] ?? '',
'COPYRIGHT': metadata['copyright'] ?? '',
'COMPOSER': metadata['composer'] ?? '',
@@ -5568,6 +5486,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final existingMetadata = await PlatformBridge.readFileMetadata(
ffmpegTarget,
);
final existingLyrics = existingMetadata['lyrics']?.toString().trim();
if (existingLyrics != null && existingLyrics.isNotEmpty) {
vorbisMap['LYRICS'] = existingLyrics;
vorbisMap['UNSYNCEDLYRICS'] = existingLyrics;
}
// Preserve ReplayGain tags if present these are computed once
// during download and should survive manual metadata edits.
final rgFields = <String, String>{
@@ -5794,12 +5717,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
_field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl),
_field(
context.l10n.trackLyrics,
_lyricsCtrl,
maxLines: 8,
keyboard: TextInputType.multiline,
),
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell(

Some files were not shown because too many files have changed in this diff Show More