Compare commits

...

11 Commits

Author SHA1 Message Date
zarzet 8b18bef5ab feat: add history message to donate notice card, fix l10n_id formatting 2026-02-07 13:53:13 +07:00
zarzet 76b01fb837 fix: SAF file descriptor handling to avoid ParcelFileDescriptor detach warning 2026-02-07 13:52:57 +07:00
zarzet 219ea593dd chore: add l10n strings for incremental scan and orphan cleanup 2026-02-07 13:20:15 +07:00
zarzet 5c54e04b69 feat: cleanup orphaned downloads from history 2026-02-07 13:20:00 +07:00
zarzet bef07b1583 feat: incremental library scan support and force full scan button 2026-02-07 13:19:46 +07:00
zarzet 859762e35c fix(l10n): improve Indonesian wording for orphaned download cleanup 2026-02-07 13:14:13 +07:00
zarzet ca136b8e17 fix: stabilize incremental library scan and fold 3.5.1 into 3.5.0 2026-02-07 13:11:23 +07:00
zarzet 03d29a73f7 feat: donate page - add GitHub Sponsors, custom icons, improved notice card 2026-02-07 12:40:46 +07:00
zarzet c6ee9cda35 fix: resolve Go staticcheck warnings in audio_metadata.go and qobuz.go 2026-02-07 11:58:46 +07:00
zarzet ad3fefac0b fix: skip tutorial for existing users upgrading to 3.5.0
Migration v2: auto-set hasCompletedTutorial=true when isFirstLaunch
is already false (existing users who completed setup before tutorial
feature was added)
2026-02-07 11:55:43 +07:00
zarzet ad606cca53 feat: v3.5.0 - SAF storage, onboarding redesign, library scan fixes
- SAF Storage Access Framework for Android 10+ downloads
- Redesigned Setup/Tutorial screens with Material 3 Expressive
- Library scan hero card now shows real-time scanned count
- Library folder picker uses SAF (no MANAGE_EXTERNAL_STORAGE needed)
- SAF migration prompt for users updating from pre-SAF versions
- Home feed caching, donate page, per-app language support
- Merged 3.6.0-beta.1 changelog entries into 3.5.0
2026-02-07 11:48:37 +07:00
45 changed files with 13034 additions and 7174 deletions
+36 -4
View File
@@ -1,12 +1,14 @@
# Changelog
## [3.5.0-beta.1] - 2026-02-06
## [3.5.0] - 2026-02-07
### Highlights
- **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs)
- Select download folder via SAF tree picker
- Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths
- Works around Android 10+ scoped storage permission errors
- **Modern Onboarding Experience**: Completely redesigned Setup and Tutorial screens
### Added
@@ -15,10 +17,20 @@
- New settings fields for storage mode + SAF tree URI
- SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF
- SAF library scan mode (DocumentFile traversal + metadata read)
- Incremental library scanning for filesystem and SAF paths (only scans new/modified files and detects removed files)
- Force Full Scan action in Library Settings to rescan all files on demand
- Downloaded files are now excluded from Local Library scan results to prevent duplicate entries
- Legacy library rows now support `file_mod_time` backfill before incremental scans (faster follow-up scans after upgrade)
- Library UI toggle to show SAF-repaired history items
- Scan cancelled banner + retry action for library scans
- Android DocumentFile dependency for SAF operations
- Post-processing API v2 (SAF-aware, ready to replace v1)
- Donate page in Settings with Ko-fi and Buy Me a Coffee links
- Per-App Language support on Android 13+ (locale_config.xml)
- Interactive tutorial with working search bar simulation and clickable download buttons
- Tutorial completion state is persisted after onboarding
- Visual feedback animations for page transitions, entrance effects, and feature lists
- New dedicated welcome step in setup wizard with improved branding
### Changed
@@ -26,11 +38,22 @@
- Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled
- Post-processing hooks run for SAF content URIs (via temp file bridge)
- File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`)
- Local Library scan defaults to incremental mode; full rescan is available via Force Full Scan
- Local library database upgraded to schema v3 with `file_mod_time` tracking for incremental scan cache
- Platform channels expanded with incremental scan APIs (`scanLibraryFolderIncremental`) on Android and iOS
- Android platform channel adds `getSafFileModTimes` for SAF legacy cache backfill
- Android build tooling upgraded to Gradle 9.3.1 (wrapper)
- Android build path validated with Java 25 (Gradle/Kotlin/assemble debug)
- SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`)
- `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility
- Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed
- Setup screen UI polish: smaller logo, thin outline borders on text fields
- Removed support section from About page (moved to Donate page)
- Qobuz squid.wtf region fallback for blocked regions
- Setup screen converted to PageView flow with animated progress bar and modern card layouts
- Tutorial screen aligned with Setup Screen design, updated typography and softened UI shapes
- Larger, more accessible navigation buttons for onboarding flow
- Reduced visual noise by removing unnecessary glow effects
### Fixed
@@ -42,6 +65,15 @@
- Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices)
- Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support
- SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling
- SAF share file now copies to temp before sharing (fixes share from SAF content URI)
- Home feed not updating after installing extension with homeFeed capability (no longer requires app restart)
- Library scan hero card showing 0 tracks during scan (now shows scanned file count in real-time)
- Library folder picker no longer requires MANAGE_EXTERNAL_STORAGE on Android 10+ (uses SAF tree picker)
- One-time SAF migration prompt for users updating from pre-SAF versions
- Fixed `fileModTime` propagation across Go/Android/Dart so incremental scan cache is stored and reused correctly
- Fixed SAF incremental scan key mismatch (`lastModified` vs `fileModTime`) and normalized result fields (`skippedCount`, `totalFiles`)
- Fixed incremental scan progress when all files are skipped (`scanned_files` now reaches total files)
- Removed duplicate `"removeExtension"` branch in Android method channel handler (eliminates Kotlin duplicate-branch warning)
---
@@ -150,7 +182,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
- **Album/Playlist Search**: Deezer search now includes albums and playlists
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re*Index.(ot_inc))
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re\*Index.(ot_inc))
- **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed
- **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks
@@ -270,7 +302,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
- Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model
- Metadata is stored in download history and persists across app restarts
- New localization strings: `trackGenre`, `trackLabel`, `trackCopyright`
- `**utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
- `**utils.randomUserAgent()` for Extensions\*\*: New utility function for extensions to get random browser User-Agent strings
- Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0`
- Useful for extensions that need to rotate User-Agents to avoid detection
@@ -567,4 +599,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
---
*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode
@@ -498,10 +499,20 @@ class MainActivity: FlutterFragmentActivity() {
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
?: return errorJson("Failed to open SAF file")
var detachedFd: Int? = null
var fdHandedOffToGo = false
try {
detachedFd = pfd.detachFd()
// Keep the original PFD open so the document provider receives close signaling.
// Pass a duplicated FD to Go and detach only the duplicate.
val writerPfd = ParcelFileDescriptor.dup(pfd.fileDescriptor)
val detachedFd = writerPfd.detachFd()
try {
writerPfd.close()
} catch (_: Exception) {}
// After detach, ownership is intended for Go. Kotlin must never close this FD,
// otherwise Android fdsan may abort on double-close during cancellation races.
fdHandedOffToGo = true
req.put("output_path", "/proc/self/fd/$detachedFd")
req.put("output_fd", detachedFd)
req.put("output_ext", outputExt)
@@ -518,11 +529,12 @@ class MainActivity: FlutterFragmentActivity() {
document.delete()
return errorJson("SAF download failed: ${e.message}")
} finally {
if (detachedFd == null) {
try {
pfd.close()
} catch (_: Exception) {}
if (!fdHandedOffToGo) {
android.util.Log.w("SpotiFLAC", "SAF writer FD was not handed off to Go")
}
try {
pfd.close()
} catch (_: Exception) {}
}
}
@@ -606,7 +618,9 @@ class MainActivity: FlutterFragmentActivity() {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val lastModified = doc.lastModified()
obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", lastModified)
results.put(obj)
} else {
errors++
@@ -637,6 +651,226 @@ class MainActivity: FlutterFragmentActivity() {
return results.toString()
}
/**
* Incremental SAF tree scan - only scans new or modified files.
* @param treeUriStr The SAF tree URI to scan
* @param existingFilesJson JSON object mapping file URI -> lastModified timestamp
* @return JSON object with new/changed files and removed URIs
*/
private fun scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): String {
if (treeUriStr.isBlank()) {
val result = JSONObject()
result.put("files", JSONArray())
result.put("removedUris", JSONArray())
result.put("skippedCount", 0)
result.put("totalFiles", 0)
return result.toString()
}
val treeUri = Uri.parse(treeUriStr)
val root = DocumentFile.fromTreeUri(this, treeUri) ?: run {
val result = JSONObject()
result.put("files", JSONArray())
result.put("removedUris", JSONArray())
result.put("skippedCount", 0)
result.put("totalFiles", 0)
return result.toString()
}
// Parse existing files map: URI -> lastModified
val existingFiles = mutableMapOf<String, Long>()
try {
val obj = JSONObject(existingFilesJson)
val keys = obj.keys()
while (keys.hasNext()) {
val key = keys.next()
existingFiles[key] = obj.optLong(key, 0)
}
} catch (_: Exception) {}
resetSafScanProgress()
safScanCancel = false
safScanActive = true
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
val currentUris = mutableSetOf<String>()
// Collect all audio files with lastModified
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "")
while (queue.isNotEmpty()) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
val result = JSONObject()
result.put("files", JSONArray())
result.put("removedUris", JSONArray())
result.put("skippedCount", 0)
result.put("totalFiles", 0)
result.put("cancelled", true)
return result.toString()
}
val (dir, path) = queue.removeFirst()
for (child in dir.listFiles()) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
val result = JSONObject()
result.put("files", JSONArray())
result.put("removedUris", JSONArray())
result.put("skippedCount", 0)
result.put("totalFiles", 0)
result.put("cancelled", true)
return result.toString()
}
if (child.isDirectory) {
val childName = child.name ?: continue
val childPath = if (path.isBlank()) childName else "$path/$childName"
queue.add(child to childPath)
} else if (child.isFile) {
val name = child.name ?: continue
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
val uriStr = child.uri.toString()
val lastModified = child.lastModified()
currentUris.add(uriStr)
// Check if file is new or modified
val existingModified = existingFiles[uriStr]
if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child, path, lastModified))
}
}
}
}
}
// Find removed files (in existing but not in current)
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
val totalFiles = currentUris.size
val skippedCount = (totalFiles - audioFiles.size).coerceAtLeast(0)
updateSafScanProgress {
it.totalFiles = totalFiles
}
if (audioFiles.isEmpty()) {
updateSafScanProgress {
it.isComplete = true
it.scannedFiles = totalFiles
it.progressPct = 100.0
}
val result = JSONObject()
result.put("files", JSONArray())
result.put("removedUris", JSONArray(removedUris))
result.put("skippedCount", skippedCount)
result.put("totalFiles", totalFiles)
return result.toString()
}
val results = JSONArray()
var scanned = 0
var errors = 0
for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
val result = JSONObject()
result.put("files", JSONArray())
result.put("removedUris", JSONArray())
result.put("skippedCount", skippedCount)
result.put("totalFiles", totalFiles)
result.put("cancelled", true)
return result.toString()
}
val name = doc.name ?: ""
updateSafScanProgress {
it.currentFile = name
}
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val tempPath = copyUriToTemp(doc.uri, fallbackExt)
if (tempPath == null) {
errors++
} else {
try {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", lastModified)
obj.put("lastModified", lastModified)
results.put(obj)
} else {
errors++
}
} catch (_: Exception) {
errors++
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
}
scanned++
val processed = skippedCount + scanned
val pct = if (totalFiles > 0) {
processed.toDouble() / totalFiles.toDouble() * 100.0
} else {
100.0
}
updateSafScanProgress {
it.scannedFiles = processed
it.errorCount = errors
it.progressPct = pct
}
}
updateSafScanProgress {
it.isComplete = true
it.progressPct = 100.0
}
val result = JSONObject()
result.put("files", results)
result.put("removedUris", JSONArray(removedUris))
result.put("skippedCount", skippedCount)
result.put("totalFiles", totalFiles)
return result.toString()
}
/**
* Resolve SAF file last-modified values for a list of content URIs.
* Returns JSON object mapping uri -> lastModified (unix millis).
*/
private fun getSafFileModTimes(urisJson: String): String {
val result = JSONObject()
val uris = try {
JSONArray(urisJson)
} catch (_: Exception) {
JSONArray()
}
for (i in 0 until uris.length()) {
val uriStr = uris.optString(i, "")
if (uriStr.isBlank()) continue
try {
val uri = Uri.parse(uriStr)
val doc = DocumentFile.fromSingleUri(this, uri)
if (doc != null && doc.exists()) {
result.put(uriStr, doc.lastModified())
}
} catch (_: Exception) {}
}
return result.toString()
}
private fun runPostProcessingSaf(fileUriStr: String, metadataJson: String): String {
val uri = Uri.parse(fileUriStr)
val doc = DocumentFile.fromSingleUri(this, uri)
@@ -1039,6 +1273,26 @@ class MainActivity: FlutterFragmentActivity() {
result.error("open_failed", e.message, null)
}
}
"shareContentUri" -> {
val uriStr = call.argument<String>("uri") ?: ""
val title = call.argument<String>("title") ?: ""
try {
val uri = Uri.parse(uriStr)
val type = contentResolver.getType(uri) ?: "audio/*"
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
setType(type)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
if (title.isNotBlank()) {
putExtra(Intent.EXTRA_SUBJECT, title)
}
}
startActivity(Intent.createChooser(shareIntent, title.ifBlank { "Share" }))
result.success(true)
} catch (e: Exception) {
result.error("share_failed", e.message, null)
}
}
"fetchLyrics" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val trackName = call.argument<String>("track_name") ?: ""
@@ -1456,13 +1710,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"removeExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
withContext(Dispatchers.IO) {
Gobackend.removeExtensionByID(extensionId)
}
result.success(null)
}
"cleanupExtensions" -> {
withContext(Dispatchers.IO) {
Gobackend.cleanupExtensions()
@@ -1726,6 +1973,15 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"scanLibraryFolderIncremental" -> {
val folderPath = call.argument<String>("folder_path") ?: ""
val existingFiles = call.argument<String>("existing_files") ?: "{}"
val response = withContext(Dispatchers.IO) {
safScanActive = false
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
}
result.success(response)
}
"scanSafTree" -> {
val treeUri = call.argument<String>("tree_uri") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -1733,6 +1989,21 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"scanSafTreeIncremental" -> {
val treeUri = call.argument<String>("tree_uri") ?: ""
val existingFiles = call.argument<String>("existing_files") ?: "{}"
val response = withContext(Dispatchers.IO) {
scanSafTreeIncremental(treeUri, existingFiles)
}
result.success(response)
}
"getSafFileModTimes" -> {
val uris = call.argument<String>("uris") ?: "[]"
val response = withContext(Dispatchers.IO) {
getSafFileModTimes(uris)
}
result.success(response)
}
"getLibraryScanProgress" -> {
val response = withContext(Dispatchers.IO) {
if (safScanActive) {
+8 -14
View File
@@ -430,11 +430,12 @@ func extendedHeaderSize(data []byte, version byte) int {
return 0
}
var size int
if version == 3 {
switch version {
case 3:
size = int(binary.BigEndian.Uint32(data[:4]))
} else if version == 4 {
case 4:
size = syncsafeToInt(data[:4])
} else {
default:
return 0
}
if size <= 0 {
@@ -624,14 +625,6 @@ func readOggPageWithHeader(file *os.File) (*oggPage, error) {
}, nil
}
func readOggPage(file *os.File) ([]byte, error) {
page, err := readOggPageWithHeader(file)
if err != nil {
return nil, err
}
return page.data, nil
}
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
const maxPacketSize = 10 * 1024 * 1024
var packets [][]byte
@@ -930,11 +923,12 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
}
var frameSize int
if majorVersion == 2 {
switch majorVersion {
case 2:
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
} else if majorVersion == 4 {
case 4:
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
} else {
default:
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
}
+7
View File
@@ -2195,6 +2195,13 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)
}
// ScanLibraryFolderIncrementalJSON performs an incremental library scan
// existingFilesJSON: JSON object mapping filePath -> modTime (unix millis)
// Returns IncrementalScanResult as JSON
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
}
func GetLibraryScanProgressJSON() string {
return GetLibraryScanProgress()
}
+194
View File
@@ -20,6 +20,7 @@ type LibraryScanResult struct {
FilePath string `json:"filePath"`
CoverPath string `json:"coverPath,omitempty"`
ScannedAt string `json:"scannedAt"`
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"`
DiscNumber int `json:"discNumber,omitempty"`
@@ -40,6 +41,14 @@ type LibraryScanProgress struct {
IsComplete bool `json:"is_complete"`
}
// IncrementalScanResult contains results of an incremental library scan
type IncrementalScanResult struct {
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
SkippedCount int `json:"skippedCount"` // Files that were unchanged
TotalFiles int `json:"totalFiles"` // Total files in folder
}
var (
libraryScanProgress LibraryScanProgress
libraryScanProgressMu sync.RWMutex
@@ -179,6 +188,11 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
Format: strings.TrimPrefix(ext, "."),
}
// Get file modification time
if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
@@ -413,3 +427,183 @@ func ReadAudioMetadata(filePath string) (string, error) {
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
if folderPath == "" {
return "{}", fmt.Errorf("folder path is empty")
}
info, err := os.Stat(folderPath)
if err != nil {
return "{}", fmt.Errorf("folder not found: %w", err)
}
if !info.IsDir() {
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
// Parse existing files map
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
}
}
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
// Reset progress
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
// Setup cancellation
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
}
libraryScanCancel = make(chan struct{})
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
// Collect all audio files with their mod times
type fileInfo struct {
path string
modTime int64
}
var currentFiles []fileInfo
currentPathSet := make(map[string]bool)
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if !info.IsDir() {
ext := strings.ToLower(filepath.Ext(path))
if supportedAudioFormats[ext] {
currentFiles = append(currentFiles, fileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
})
currentPathSet[path] = true
}
}
return nil
})
if err != nil {
return "{}", err
}
totalFiles := len(currentFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
// Find files to scan (new or modified)
var filesToScan []fileInfo
skippedCount := 0
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// New file
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
// Modified file
filesToScan = append(filesToScan, f)
} else {
// Unchanged file - skip
skippedCount++
}
}
// Find deleted files
var deletedPaths []string
for existingPath := range existingFiles {
if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
}
}
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
len(filesToScan), skippedCount, len(deletedPaths))
if len(filesToScan) == 0 {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.IsComplete = true
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
result := IncrementalScanResult{
Scanned: []LibraryScanResult{},
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
// Scan the files that need scanning
results := make([]LibraryScanResult, 0, len(filesToScan))
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
for i, f := range filesToScan {
select {
case <-cancelCh:
return "{}", fmt.Errorf("scan cancelled")
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = skippedCount + i + 1
libraryScanProgress.CurrentFile = filepath.Base(f.path)
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
result, err := scanAudioFile(f.path, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
continue
}
results = append(results, *result)
}
libraryScanProgressMu.Lock()
libraryScanProgress.ErrorCount = errorCount
libraryScanProgress.IsComplete = true
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
len(results), skippedCount, len(deletedPaths), errorCount)
scanResult := IncrementalScanResult{
Scanned: results,
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, err := json.Marshal(scanResult)
if err != nil {
return "{}", fmt.Errorf("failed to marshal results: %w", err)
}
return string(jsonBytes), nil
}
+29 -4
View File
@@ -764,10 +764,9 @@ type qobuzAPIResult struct {
// Qobuz API timeout configuration
// Mobile networks are more unstable, so we use longer timeouts
const (
qobuzAPITimeoutDesktop = 15 * time.Second
qobuzAPITimeoutMobile = 25 * time.Second
qobuzMaxRetries = 2 // Number of retries per API
qobuzRetryDelay = 500 * time.Millisecond
qobuzAPITimeoutMobile = 25 * time.Second
qobuzMaxRetries = 2 // Number of retries per API
qobuzRetryDelay = 500 * time.Millisecond
)
// getQobuzAPITimeout returns appropriate timeout based on platform
@@ -778,8 +777,31 @@ func getQobuzAPITimeout() time.Duration {
return qobuzAPITimeoutMobile
}
// qobuzSquidCountries defines the region fallback order for squid.wtf API
var qobuzSquidCountries = []string{"US", "FR"}
// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic
// For squid.wtf APIs, it tries US region first, then falls back to FR
func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) {
isSquid := strings.Contains(api, "squid.wtf")
if isSquid {
for _, country := range qobuzSquidCountries {
GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country)
result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country)
if err == nil {
return result, nil
}
GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err)
}
return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)")
}
return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "")
}
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) {
var lastErr error
retryDelay := qobuzRetryDelay
@@ -792,6 +814,9 @@ func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout t
client := NewHTTPClientWithTimeout(timeout)
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
if country != "" {
reqURL += "&country=" + country
}
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
+8
View File
@@ -723,6 +723,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "scanLibraryFolderIncremental":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String
let existingFiles = args["existing_files"] as? String ?? "{}"
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
if let error = error { throw error }
return response
case "getLibraryScanProgress":
let response = GobackendGetLibraryScanProgressJSON()
return response
+17 -1
View File
@@ -4,15 +4,27 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/screens/tutorial_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
// Determine initial location based on app state
String initialLocation;
if (isFirstLaunch) {
initialLocation = '/setup';
} else if (!hasCompletedTutorial) {
initialLocation = '/tutorial';
} else {
initialLocation = '/';
}
return GoRouter(
initialLocation: isFirstLaunch ? '/setup' : '/',
initialLocation: initialLocation,
routes: [
GoRoute(
path: '/',
@@ -22,6 +34,10 @@ final _routerProvider = Provider<GoRouter>((ref) {
path: '/setup',
builder: (context, state) => const SetupScreen(),
),
GoRoute(
path: '/tutorial',
builder: (context, state) => const TutorialScreen(),
),
],
);
});
+4 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.5.0-beta.1';
static const String buildNumber = '73';
static const String version = '3.5.0';
static const String buildNumber = '74';
static const String fullVersion = '$version+$buildNumber';
@@ -17,4 +17,6 @@ class AppInfo {
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String bmacUrl = 'https://buymeacoffee.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
}
+336
View File
@@ -4419,6 +4419,342 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'**
String timeHoursAgo(int count);
/// Dialog title when switching storage mode
///
/// In en, this message translates to:
/// **'Switch Storage Mode'**
String get storageSwitchTitle;
/// Dialog title when switching to SAF
///
/// In en, this message translates to:
/// **'Switch to SAF Storage?'**
String get storageSwitchToSafTitle;
/// Dialog title when switching to app storage
///
/// In en, this message translates to:
/// **'Switch to App Storage?'**
String get storageSwitchToAppTitle;
/// Explanation when switching to SAF
///
/// In en, this message translates to:
/// **'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'**
String get storageSwitchToSafMessage;
/// Explanation when switching to app storage
///
/// In en, this message translates to:
/// **'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'**
String get storageSwitchToAppMessage;
/// Section header for existing downloads info
///
/// In en, this message translates to:
/// **'Existing Downloads'**
String get storageSwitchExistingDownloads;
/// Info about existing downloads count
///
/// In en, this message translates to:
/// **'{count} tracks in {mode} storage'**
String storageSwitchExistingDownloadsInfo(int count, String mode);
/// Section header for new downloads info
///
/// In en, this message translates to:
/// **'New Downloads'**
String get storageSwitchNewDownloads;
/// Shows where new downloads will go
///
/// In en, this message translates to:
/// **'Will be saved to: {location}'**
String storageSwitchNewDownloadsLocation(String location);
/// Button to proceed with storage switch
///
/// In en, this message translates to:
/// **'Continue'**
String get storageSwitchContinue;
/// Button to select SAF folder
///
/// In en, this message translates to:
/// **'Select SAF Folder'**
String get storageSwitchSelectFolder;
/// Label for app storage mode
///
/// In en, this message translates to:
/// **'App Storage'**
String get storageAppStorage;
/// Label for SAF storage mode
///
/// In en, this message translates to:
/// **'SAF Storage'**
String get storageSafStorage;
/// Badge showing storage mode for a track
///
/// In en, this message translates to:
/// **'Storage: {mode}'**
String storageModeBadge(String mode);
/// Section title for storage stats
///
/// In en, this message translates to:
/// **'Storage Statistics'**
String get storageStatsTitle;
/// Count of tracks in app storage
///
/// In en, this message translates to:
/// **'{count} tracks in App Storage'**
String storageStatsAppCount(int count);
/// Count of tracks in SAF storage
///
/// In en, this message translates to:
/// **'{count} tracks in SAF Storage'**
String storageStatsSafCount(int count);
/// Info when user has files in both storage modes
///
/// In en, this message translates to:
/// **'Your files are stored in multiple locations'**
String get storageModeInfo;
/// Tutorial welcome page title
///
/// In en, this message translates to:
/// **'Welcome to SpotiFLAC!'**
String get tutorialWelcomeTitle;
/// Tutorial welcome page description
///
/// In en, this message translates to:
/// **'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'**
String get tutorialWelcomeDesc;
/// Tutorial welcome tip 1
///
/// In en, this message translates to:
/// **'Download music from Spotify, Deezer, or paste any supported URL'**
String get tutorialWelcomeTip1;
/// Tutorial welcome tip 2
///
/// In en, this message translates to:
/// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'**
String get tutorialWelcomeTip2;
/// Tutorial welcome tip 3
///
/// In en, this message translates to:
/// **'Automatic metadata, cover art, and lyrics embedding'**
String get tutorialWelcomeTip3;
/// Tutorial search page title
///
/// In en, this message translates to:
/// **'Finding Music'**
String get tutorialSearchTitle;
/// Tutorial search page description
///
/// In en, this message translates to:
/// **'There are two easy ways to find music you want to download.'**
String get tutorialSearchDesc;
/// Tutorial search tip 1
///
/// In en, this message translates to:
/// **'Paste a Spotify or Deezer URL directly in the search box'**
String get tutorialSearchTip1;
/// Tutorial search tip 2
///
/// In en, this message translates to:
/// **'Or type the song name, artist, or album to search'**
String get tutorialSearchTip2;
/// Tutorial search tip 3
///
/// In en, this message translates to:
/// **'Supports tracks, albums, playlists, and artist pages'**
String get tutorialSearchTip3;
/// Tutorial download page title
///
/// In en, this message translates to:
/// **'Downloading Music'**
String get tutorialDownloadTitle;
/// Tutorial download page description
///
/// In en, this message translates to:
/// **'Downloading music is simple and fast. Here\'s how it works.'**
String get tutorialDownloadDesc;
/// Tutorial download tip 1
///
/// In en, this message translates to:
/// **'Tap the download button next to any track to start downloading'**
String get tutorialDownloadTip1;
/// Tutorial download tip 2
///
/// In en, this message translates to:
/// **'Choose your preferred quality (FLAC, Hi-Res, or MP3)'**
String get tutorialDownloadTip2;
/// Tutorial download tip 3
///
/// In en, this message translates to:
/// **'Download entire albums or playlists with one tap'**
String get tutorialDownloadTip3;
/// Tutorial library page title
///
/// In en, this message translates to:
/// **'Your Library'**
String get tutorialLibraryTitle;
/// Tutorial library page description
///
/// In en, this message translates to:
/// **'All your downloaded music is organized in the Library tab.'**
String get tutorialLibraryDesc;
/// Tutorial library tip 1
///
/// In en, this message translates to:
/// **'View download progress and queue in the Library tab'**
String get tutorialLibraryTip1;
/// Tutorial library tip 2
///
/// In en, this message translates to:
/// **'Tap any track to play it with your music player'**
String get tutorialLibraryTip2;
/// Tutorial library tip 3
///
/// In en, this message translates to:
/// **'Switch between list and grid view for better browsing'**
String get tutorialLibraryTip3;
/// Tutorial extensions page title
///
/// In en, this message translates to:
/// **'Extensions'**
String get tutorialExtensionsTitle;
/// Tutorial extensions page description
///
/// In en, this message translates to:
/// **'Extend the app\'s capabilities with community extensions.'**
String get tutorialExtensionsDesc;
/// Tutorial extensions tip 1
///
/// In en, this message translates to:
/// **'Browse the Store tab to discover useful extensions'**
String get tutorialExtensionsTip1;
/// Tutorial extensions tip 2
///
/// In en, this message translates to:
/// **'Add new download providers or search sources'**
String get tutorialExtensionsTip2;
/// Tutorial extensions tip 3
///
/// In en, this message translates to:
/// **'Get lyrics, enhanced metadata, and more features'**
String get tutorialExtensionsTip3;
/// Tutorial settings page title
///
/// In en, this message translates to:
/// **'Customize Your Experience'**
String get tutorialSettingsTitle;
/// Tutorial settings page description
///
/// In en, this message translates to:
/// **'Personalize the app in Settings to match your preferences.'**
String get tutorialSettingsDesc;
/// Tutorial settings tip 1
///
/// In en, this message translates to:
/// **'Change download location and folder organization'**
String get tutorialSettingsTip1;
/// Tutorial settings tip 2
///
/// In en, this message translates to:
/// **'Set default audio quality and format preferences'**
String get tutorialSettingsTip2;
/// Tutorial settings tip 3
///
/// In en, this message translates to:
/// **'Customize app theme and appearance'**
String get tutorialSettingsTip3;
/// Tutorial completion message
///
/// In en, this message translates to:
/// **'You\'re all set! Start downloading your favorite music now.'**
String get tutorialReadyMessage;
/// Example label in tutorial
///
/// In en, this message translates to:
/// **'EXAMPLE'**
String get tutorialExample;
/// Button to force a complete rescan of library
///
/// In en, this message translates to:
/// **'Force Full Scan'**
String get libraryForceFullScan;
/// Subtitle for force full scan button
///
/// In en, this message translates to:
/// **'Rescan all files, ignoring cache'**
String get libraryForceFullScanSubtitle;
/// Button to remove history entries for deleted files
///
/// In en, this message translates to:
/// **'Cleanup Orphaned Downloads'**
String get cleanupOrphanedDownloads;
/// Subtitle for orphaned cleanup button
///
/// In en, this message translates to:
/// **'Remove history entries for files that no longer exist'**
String get cleanupOrphanedDownloadsSubtitle;
/// Snackbar after orphan cleanup
///
/// In en, this message translates to:
/// **'Removed {count} orphaned entries from history'**
String cleanupOrphanedDownloadsResult(int count);
/// Snackbar when no orphans found
///
/// In en, this message translates to:
/// **'No orphaned entries found'**
String get cleanupOrphanedDownloadsNone;
}
class _AppLocalizationsDelegate
+207
View File
@@ -2471,4 +2471,211 @@ class AppLocalizationsDe extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
+207
View File
@@ -2456,4 +2456,211 @@ class AppLocalizationsEn extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
+207
View File
@@ -2456,6 +2456,213 @@ class AppLocalizationsEs extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+207
View File
@@ -2456,4 +2456,211 @@ class AppLocalizationsFr extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
+207
View File
@@ -2456,4 +2456,211 @@ class AppLocalizationsHi extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
File diff suppressed because it is too large Load Diff
+207
View File
@@ -2442,4 +2442,211 @@ class AppLocalizationsJa extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
+207
View File
@@ -2456,4 +2456,211 @@ class AppLocalizationsKo extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
+207
View File
@@ -2456,4 +2456,211 @@ class AppLocalizationsNl extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
+207
View File
@@ -2456,6 +2456,213 @@ class AppLocalizationsPt extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+207
View File
@@ -2502,4 +2502,211 @@ class AppLocalizationsRu extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
+207
View File
@@ -2471,4 +2471,211 @@ class AppLocalizationsTr extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
+207
View File
@@ -2456,6 +2456,213 @@ class AppLocalizationsZh extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
}
/// The translations for Chinese, as used in China (`zh_CN`).
+154 -1
View File
@@ -1847,5 +1847,158 @@
"placeholders": {
"count": {"type": "int"}
}
}
},
"storageSwitchTitle": "Switch Storage Mode",
"@storageSwitchTitle": {"description": "Dialog title when switching storage mode"},
"storageSwitchToSafTitle": "Switch to SAF Storage?",
"@storageSwitchToSafTitle": {"description": "Dialog title when switching to SAF"},
"storageSwitchToAppTitle": "Switch to App Storage?",
"@storageSwitchToAppTitle": {"description": "Dialog title when switching to app storage"},
"storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.",
"@storageSwitchToSafMessage": {"description": "Explanation when switching to SAF"},
"storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.",
"@storageSwitchToAppMessage": {"description": "Explanation when switching to app storage"},
"storageSwitchExistingDownloads": "Existing Downloads",
"@storageSwitchExistingDownloads": {"description": "Section header for existing downloads info"},
"storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage",
"@storageSwitchExistingDownloadsInfo": {
"description": "Info about existing downloads count",
"placeholders": {
"count": {"type": "int"},
"mode": {"type": "String"}
}
},
"storageSwitchNewDownloads": "New Downloads",
"@storageSwitchNewDownloads": {"description": "Section header for new downloads info"},
"storageSwitchNewDownloadsLocation": "Will be saved to: {location}",
"@storageSwitchNewDownloadsLocation": {
"description": "Shows where new downloads will go",
"placeholders": {
"location": {"type": "String"}
}
},
"storageSwitchContinue": "Continue",
"@storageSwitchContinue": {"description": "Button to proceed with storage switch"},
"storageSwitchSelectFolder": "Select SAF Folder",
"@storageSwitchSelectFolder": {"description": "Button to select SAF folder"},
"storageAppStorage": "App Storage",
"@storageAppStorage": {"description": "Label for app storage mode"},
"storageSafStorage": "SAF Storage",
"@storageSafStorage": {"description": "Label for SAF storage mode"},
"storageModeBadge": "Storage: {mode}",
"@storageModeBadge": {
"description": "Badge showing storage mode for a track",
"placeholders": {
"mode": {"type": "String"}
}
},
"storageStatsTitle": "Storage Statistics",
"@storageStatsTitle": {"description": "Section title for storage stats"},
"storageStatsAppCount": "{count} tracks in App Storage",
"@storageStatsAppCount": {
"description": "Count of tracks in app storage",
"placeholders": {
"count": {"type": "int"}
}
},
"storageStatsSafCount": "{count} tracks in SAF Storage",
"@storageStatsSafCount": {
"description": "Count of tracks in SAF storage",
"placeholders": {
"count": {"type": "int"}
}
},
"storageModeInfo": "Your files are stored in multiple locations",
"@storageModeInfo": {"description": "Info when user has files in both storage modes"},
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
"@tutorialWelcomeTitle": {"description": "Tutorial welcome page title"},
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
"@tutorialWelcomeDesc": {"description": "Tutorial welcome page description"},
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
"@tutorialWelcomeTip1": {"description": "Tutorial welcome tip 1"},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"@tutorialWelcomeTip2": {"description": "Tutorial welcome tip 2"},
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
"@tutorialWelcomeTip3": {"description": "Tutorial welcome tip 3"},
"tutorialSearchTitle": "Finding Music",
"@tutorialSearchTitle": {"description": "Tutorial search page title"},
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
"@tutorialSearchDesc": {"description": "Tutorial search page description"},
"tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box",
"@tutorialSearchTip1": {"description": "Tutorial search tip 1"},
"tutorialSearchTip2": "Or type the song name, artist, or album to search",
"@tutorialSearchTip2": {"description": "Tutorial search tip 2"},
"tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages",
"@tutorialSearchTip3": {"description": "Tutorial search tip 3"},
"tutorialDownloadTitle": "Downloading Music",
"@tutorialDownloadTitle": {"description": "Tutorial download page title"},
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
"@tutorialDownloadDesc": {"description": "Tutorial download page description"},
"tutorialDownloadTip1": "Tap the download button next to any track to start downloading",
"@tutorialDownloadTip1": {"description": "Tutorial download tip 1"},
"tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)",
"@tutorialDownloadTip2": {"description": "Tutorial download tip 2"},
"tutorialDownloadTip3": "Download entire albums or playlists with one tap",
"@tutorialDownloadTip3": {"description": "Tutorial download tip 3"},
"tutorialLibraryTitle": "Your Library",
"@tutorialLibraryTitle": {"description": "Tutorial library page title"},
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
"@tutorialLibraryDesc": {"description": "Tutorial library page description"},
"tutorialLibraryTip1": "View download progress and queue in the Library tab",
"@tutorialLibraryTip1": {"description": "Tutorial library tip 1"},
"tutorialLibraryTip2": "Tap any track to play it with your music player",
"@tutorialLibraryTip2": {"description": "Tutorial library tip 2"},
"tutorialLibraryTip3": "Switch between list and grid view for better browsing",
"@tutorialLibraryTip3": {"description": "Tutorial library tip 3"},
"tutorialExtensionsTitle": "Extensions",
"@tutorialExtensionsTitle": {"description": "Tutorial extensions page title"},
"tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.",
"@tutorialExtensionsDesc": {"description": "Tutorial extensions page description"},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"@tutorialExtensionsTip1": {"description": "Tutorial extensions tip 1"},
"tutorialExtensionsTip2": "Add new download providers or search sources",
"@tutorialExtensionsTip2": {"description": "Tutorial extensions tip 2"},
"tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features",
"@tutorialExtensionsTip3": {"description": "Tutorial extensions tip 3"},
"tutorialSettingsTitle": "Customize Your Experience",
"@tutorialSettingsTitle": {"description": "Tutorial settings page title"},
"tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.",
"@tutorialSettingsDesc": {"description": "Tutorial settings page description"},
"tutorialSettingsTip1": "Change download location and folder organization",
"@tutorialSettingsTip1": {"description": "Tutorial settings tip 1"},
"tutorialSettingsTip2": "Set default audio quality and format preferences",
"@tutorialSettingsTip2": {"description": "Tutorial settings tip 2"},
"tutorialSettingsTip3": "Customize app theme and appearance",
"@tutorialSettingsTip3": {"description": "Tutorial settings tip 3"},
"tutorialReadyMessage": "You're all set! Start downloading your favorite music now.",
"@tutorialReadyMessage": {"description": "Tutorial completion message"},
"tutorialExample": "EXAMPLE",
"@tutorialExample": {"description": "Example label in tutorial"},
"libraryForceFullScan": "Force Full Scan",
"@libraryForceFullScan": {"description": "Button to force a complete rescan of library"},
"libraryForceFullScanSubtitle": "Rescan all files, ignoring cache",
"@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"},
"cleanupOrphanedDownloads": "Cleanup Orphaned Downloads",
"@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"},
"cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist",
"@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"},
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
"@cleanupOrphanedDownloadsResult": {
"description": "Snackbar after orphan cleanup",
"placeholders": {
"count": {"type": "int"}
}
},
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"}
}
+3015 -2861
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -43,6 +43,9 @@ class AppSettings {
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
// Tutorial/Onboarding
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
const AppSettings({
this.defaultService = 'tidal',
@@ -83,6 +86,8 @@ class AppSettings {
this.localLibraryEnabled = false,
this.localLibraryPath = '',
this.localLibraryShowDuplicates = true,
// Tutorial default
this.hasCompletedTutorial = false,
});
AppSettings copyWith({
@@ -125,6 +130,8 @@ class AppSettings {
bool? localLibraryEnabled,
String? localLibraryPath,
bool? localLibraryShowDuplicates,
// Tutorial
bool? hasCompletedTutorial,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -165,6 +172,8 @@ class AppSettings {
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
// Tutorial
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
);
}
+2
View File
@@ -48,6 +48,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -89,4 +90,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
'hasCompletedTutorial': instance.hasCompletedTutorial,
};
@@ -408,6 +408,59 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return DownloadHistoryItem.fromJson(json);
}
/// Remove history entries where the file no longer exists on disk
/// Returns the number of orphaned entries removed
Future<int> cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...');
final entries = await _db.getAllEntriesWithPaths();
final orphanedIds = <String>[];
for (final entry in entries) {
final id = entry['id'] as String;
final filePath = entry['file_path'] as String?;
if (filePath == null || filePath.isEmpty) continue;
bool exists = false;
if (filePath.startsWith('content://')) {
// SAF path - check via platform bridge
try {
exists = await PlatformBridge.safExists(filePath);
} catch (e) {
_historyLog.w('Error checking SAF file existence: $e');
exists = false;
}
} else {
// Regular file path
exists = File(filePath).existsSync();
}
if (!exists) {
orphanedIds.add(id);
_historyLog.d('Found orphaned entry: $id ($filePath)');
}
}
if (orphanedIds.isEmpty) {
_historyLog.i('No orphaned entries found');
return 0;
}
// Delete from database
final deletedCount = await _db.deleteByIds(orphanedIds);
// Update in-memory state
final orphanedSet = orphanedIds.toSet();
state = state.copyWith(
items: state.items.where((item) => !orphanedSet.contains(item.id)).toList(),
);
_historyLog.i('Cleaned up $deletedCount orphaned entries');
return deletedCount;
}
void clearHistory() {
state = DownloadHistoryState();
_db.clearAll().catchError((e) {
+219 -34
View File
@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -16,6 +18,7 @@ class LocalLibraryState {
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
final int scannedFiles;
final int scanErrorCount;
final bool scanWasCancelled;
final DateTime? lastScannedAt;
@@ -29,6 +32,7 @@ class LocalLibraryState {
this.scanProgress = 0,
this.scanCurrentFile,
this.scanTotalFiles = 0,
this.scannedFiles = 0,
this.scanErrorCount = 0,
this.scanWasCancelled = false,
this.lastScannedAt,
@@ -73,6 +77,7 @@ class LocalLibraryState {
double? scanProgress,
String? scanCurrentFile,
int? scanTotalFiles,
int? scannedFiles,
int? scanErrorCount,
bool? scanWasCancelled,
DateTime? lastScannedAt,
@@ -83,6 +88,7 @@ class LocalLibraryState {
scanProgress: scanProgress ?? this.scanProgress,
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
scannedFiles: scannedFiles ?? this.scannedFiles,
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
@@ -92,6 +98,7 @@ class LocalLibraryState {
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance;
Timer? _progressTimer;
bool _isLoaded = false;
bool _scanCancelRequested = false;
@@ -141,19 +148,20 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase();
}
Future<void> startScan(String folderPath) async {
Future<void> startScan(String folderPath, {bool forceFullScan = false}) async {
if (state.isScanning) {
_log.w('Scan already in progress');
return;
}
_scanCancelRequested = false;
_log.i('Starting library scan: $folderPath');
_log.i('Starting library scan: $folderPath (incremental: ${!forceFullScan})');
state = state.copyWith(
isScanning: true,
scanProgress: 0,
scanCurrentFile: null,
scanTotalFiles: 0,
scannedFiles: 0,
scanErrorCount: 0,
scanWasCancelled: false,
);
@@ -171,40 +179,163 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final isSaf = folderPath.startsWith('content://');
final results = isSaf
? await PlatformBridge.scanSafTree(folderPath)
: await PlatformBridge.scanLibraryFolder(folderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
return;
}
final items = <LocalLibraryItem>[];
for (final json in results) {
final item = LocalLibraryItem.fromJson(json);
items.add(item);
// Get all file paths from download history to exclude them
final downloadedPaths = await _historyDb.getAllFilePaths();
_log.i('Excluding ${downloadedPaths.length} downloaded files from library scan');
if (forceFullScan) {
// Full scan path - ignores existing data
final results = isSaf
? await PlatformBridge.scanSafTree(folderPath)
: await PlatformBridge.scanLibraryFolder(folderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
return;
}
final items = <LocalLibraryItem>[];
int skippedDownloads = 0;
for (final json in results) {
final filePath = json['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) {
skippedDownloads++;
continue;
}
final item = LocalLibraryItem.fromJson(json);
items.add(item);
}
if (skippedDownloads > 0) {
_log.i('Skipped $skippedDownloads files already in download history');
}
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
}
state = state.copyWith(
items: items,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
);
_log.i('Full scan complete: ${items.length} tracks found');
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
_log.i('Incremental scan: ${existingFiles.length} existing files in database');
final backfilledModTimes = await _backfillLegacyFileModTimes(
isSaf: isSaf,
existingFiles: existingFiles,
);
if (backfilledModTimes.isNotEmpty) {
await _db.updateFileModTimes(backfilledModTimes);
existingFiles.addAll(backfilledModTimes);
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
}
// Use appropriate incremental scan method based on SAF or not
final Map<String, dynamic> result;
if (isSaf) {
result = await PlatformBridge.scanSafTreeIncremental(
folderPath,
existingFiles,
);
} else {
result = await PlatformBridge.scanLibraryFolderIncremental(
folderPath,
existingFiles,
);
}
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
return;
}
// Parse incremental scan result
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList = (result['files'] as List<dynamic>?)
?? (result['scanned'] as List<dynamic>?)
?? [];
final deletedPaths = (result['removedUris'] as List<dynamic>?)
?.map((e) => e as String)
.toList()
?? (result['deletedPaths'] as List<dynamic>?)
?.map((e) => e as String)
.toList()
?? [];
final skippedCount = result['skippedCount'] as int? ?? 0;
final totalFiles = result['totalFiles'] as int? ?? 0;
_log.i('Incremental result: ${scannedList.length} scanned, '
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total');
// Upsert new/modified items (excluding downloaded files)
if (scannedList.isNotEmpty) {
final items = <LocalLibraryItem>[];
int skippedDownloads = 0;
for (final json in scannedList) {
final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) {
skippedDownloads++;
continue;
}
items.add(LocalLibraryItem.fromJson(map));
}
if (items.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
_log.i('Upserted ${items.length} items');
}
if (skippedDownloads > 0) {
_log.i('Skipped $skippedDownloads files already in download history');
}
}
// Delete removed items
if (deletedPaths.isNotEmpty) {
final deleteCount = await _db.deleteByPaths(deletedPaths);
_log.i('Deleted $deleteCount items from database');
}
// Reload all items from database to get complete list
final allItems = await _db.getAll();
final items = allItems.map((e) => LocalLibraryItem.fromJson(e)).toList();
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
}
state = state.copyWith(
items: items,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
);
_log.i('Incremental scan complete: ${items.length} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)');
}
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
}
state = state.copyWith(
items: items,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
);
_log.i('Scan complete: ${items.length} tracks found');
} catch (e, stack) {
_log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false, scanWasCancelled: false);
@@ -223,6 +354,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
scanCurrentFile: progress['current_file'] as String?,
scanTotalFiles: progress['total_files'] as int? ?? 0,
scannedFiles: progress['scanned_files'] as int? ?? 0,
scanErrorCount: progress['error_count'] as int? ?? 0,
);
@@ -310,6 +442,59 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<int> getCount() async {
return await _db.getCount();
}
Future<Map<String, int>> _backfillLegacyFileModTimes({
required bool isSaf,
required Map<String, int> existingFiles,
}) async {
final legacyPaths = existingFiles.entries
.where((entry) => entry.value <= 0)
.map((entry) => entry.key)
.toList();
if (legacyPaths.isEmpty) {
return const {};
}
if (isSaf) {
final uris = legacyPaths
.where((path) => path.startsWith('content://'))
.toList();
if (uris.isEmpty) {
return const {};
}
const chunkSize = 500;
final backfilled = <String, int>{};
try {
for (var i = 0; i < uris.length; i += chunkSize) {
if (_scanCancelRequested) {
break;
}
final end = (i + chunkSize < uris.length) ? i + chunkSize : uris.length;
final chunk = uris.sublist(i, end);
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
backfilled.addAll(chunkResult);
}
return backfilled;
} catch (e) {
_log.w('Failed to backfill SAF mod times: $e');
return const {};
}
}
final backfilled = <String, int>{};
for (final path in legacyPaths) {
if (_scanCancelRequested || path.startsWith('content://')) {
continue;
}
try {
final stat = await File(path).stat();
if (stat.type == FileSystemEntityType.file) {
backfilled[path] = stat.modified.millisecondsSinceEpoch;
}
} catch (_) {}
}
return backfilled;
}
}
final localLibraryProvider =
+11 -1
View File
@@ -8,7 +8,7 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
const _currentMigrationVersion = 2;
const _spotifyClientSecretKey = 'spotify_client_secret';
class SettingsNotifier extends Notifier<AppSettings> {
@@ -51,7 +51,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
state = state.copyWith(storageMode: 'saf');
}
// Migration 2: existing users who already completed setup should skip tutorial
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
state = state.copyWith(hasCompletedTutorial: true);
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
}
}
@@ -325,6 +330,11 @@ void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(localLibraryShowDuplicates: show);
_saveSettings();
}
void setTutorialComplete() {
state = state.copyWith(hasCompletedTutorial: true);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+16
View File
@@ -50,6 +50,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
String? _lastSearchQuery;
late final ProviderSubscription<TrackState> _trackStateSub;
late final ProviderSubscription<bool> _extensionInitSub;
late final ProviderSubscription<bool> _homeFeedExtSub;
Timer? _liveSearchDebounce;
bool _isLiveSearchInProgress = false;
@@ -88,6 +89,20 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
},
);
// Watch for new homeFeed extension being installed/enabled after init
_homeFeedExtSub = ref.listenManual<bool>(
extensionProvider.select((s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed)),
(previous, next) {
if (next == true && previous != true) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(exploreProvider.notifier).fetchHomeFeed(forceRefresh: true);
}
});
}
},
);
}
void _fetchExploreIfNeeded() {
@@ -106,6 +121,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
_liveSearchDebounce?.cancel();
_trackStateSub.close();
_extensionInitSub.close();
_homeFeedExtSub.close();
_urlController.removeListener(_onSearchChanged);
_searchFocusNode.removeListener(_onSearchFocusChanged);
_urlController.dispose();
+87
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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';
@@ -12,6 +15,7 @@ import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/store_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
@@ -40,6 +44,7 @@ class _MainShellState extends ConsumerState<MainShell> {
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
_setupShareListener();
_checkSafMigration();
});
}
@@ -115,6 +120,88 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
static const _safMigrationShownKey = 'saf_migration_prompt_shown';
Future<void> _checkSafMigration() async {
if (!Platform.isAndroid) return;
final settings = ref.read(settingsProvider);
// Only show if user is still on legacy storage mode with a download dir set
if (settings.storageMode == 'saf') return;
if (settings.downloadDirectory.isEmpty) return;
// Check Android version
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
if (androidInfo.version.sdkInt < 29) return;
// Only show once
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_safMigrationShownKey) == true) return;
await prefs.setBool(_safMigrationShownKey, true);
if (!mounted) return;
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
icon: Icon(
Icons.folder_special_outlined,
size: 32,
color: colorScheme.primary,
),
title: const Text('Storage Update Required'),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. '
'This fixes "permission denied" errors on Android 10+.',
),
SizedBox(height: 12),
Text(
'Please select your download folder again to switch to the new storage system.',
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Later'),
),
FilledButton(
onPressed: () async {
Navigator.pop(ctx);
final result = await PlatformBridge.pickSafTree();
if (result != null) {
final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? '';
if (treeUri.isNotEmpty) {
ref.read(settingsProvider.notifier).setDownloadTreeUri(
treeUri,
displayName: displayName.isNotEmpty ? displayName : treeUri,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Download folder updated to SAF mode'),
),
);
}
}
}
},
child: const Text('Select Folder'),
),
],
),
);
}
@override
void dispose() {
_shareSubscription?.cancel();
-17
View File
@@ -191,23 +191,6 @@ _AboutSettingsItem(
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.coffee_outlined,
title: context.l10n.aboutBuyMeCoffee,
subtitle: context.l10n.aboutBuyMeCoffeeSubtitle,
onTap: () => _launchUrl(AppInfo.kofiUrl),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
+419
View File
@@ -0,0 +1,419 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/widgets/donate_icons.dart';
class DonatePage extends StatelessWidget {
const DonatePage({super.key});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
'Donate',
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Header message
Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Icon(
Icons.favorite_rounded,
size: 48,
color: colorScheme.primary,
),
const SizedBox(height: 12),
Text(
'Support SpotiFLAC-Mobile',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'SpotiFLAC-Mobile is free and open source. '
'If you enjoy using it, consider supporting '
'the development.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
),
const SizedBox(height: 16),
// Donate links card
_DonateLinksCard(colorScheme: colorScheme),
const SizedBox(height: 24),
// Recent donors section
_RecentDonorsCard(colorScheme: colorScheme),
const SizedBox(height: 12),
// Notice
Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline_rounded,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'About Supporters',
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 6),
Text(
'By supporting SpotiFLAC, you become part of this app\'s history. '
'Your name will remain in this version permanently as a token of appreciation. '
'The supporter list is updated manually each month and embedded directly in the app '
'-- no remote server is used. Even if your support period ends, your name stays in '
'every version it was included in.',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
],
),
),
),
],
),
);
}
}
class _RecentDonorsCard extends StatelessWidget {
final ColorScheme colorScheme;
const _RecentDonorsCard({required this.colorScheme});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
// Match SettingsGroup color logic
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.04),
colorScheme.surface,
);
return Card(
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.star_rounded, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'Recent Supporters',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 4),
Text(
'Thank you for your generosity!',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile(
name: '283Fabio',
colorScheme: colorScheme,
showDivider: false,
),
],
),
),
);
}
}
class _DonateLinksCard extends StatelessWidget {
final ColorScheme colorScheme;
const _DonateLinksCard({required this.colorScheme});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.04),
colorScheme.surface,
);
return Card(
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
_DonateCardItem(
title: 'Ko-fi',
subtitle: 'ko-fi.com/zarzet',
customIcon: const KofiIcon(size: 22, color: Colors.white),
color: const Color(0xFFFF5E5B),
url: AppInfo.kofiUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_DonateCardItem(
title: 'Buy Me a Coffee',
subtitle: 'buymeacoffee.com/zarzet',
customIcon: const BmacIcon(size: 22, color: Colors.black87),
color: const Color(0xFFFFDD00),
url: AppInfo.bmacUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_DonateCardItem(
title: 'GitHub Sponsors',
subtitle: 'github.com/sponsors/zarzet',
customIcon: const GitHubIcon(size: 22, color: Colors.white),
color: const Color(0xFF2D333B),
url: AppInfo.githubSponsorsUrl,
colorScheme: colorScheme,
),
],
),
);
}
}
class _DonateCardItem extends StatelessWidget {
final String title;
final String subtitle;
final Widget customIcon;
final Color color;
final String url;
final ColorScheme colorScheme;
const _DonateCardItem({
required this.title,
required this.subtitle,
required this.customIcon,
required this.color,
required this.url,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () =>
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Center(child: customIcon),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.open_in_new,
size: 18,
color: colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}
class _DonorTile extends StatelessWidget {
final String name;
final ColorScheme colorScheme;
final bool showDivider;
const _DonorTile({
required this.name,
required this.colorScheme,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: colorScheme.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 12),
Text(
name,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
),
],
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
+82 -33
View File
@@ -7,6 +7,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget {
@@ -21,6 +22,26 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
int _androidSdkVersion = 0;
bool _hasStoragePermission = false;
/// Convert SAF content URI to a readable display path
String _getDisplayPath(String path) {
if (!path.startsWith('content://')) return path;
// Extract the path portion from SAF tree URI
// e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic
// -> /storage/emulated/0/Music
try {
final uri = Uri.parse(path);
final treePath = uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
}
// For SD card or other volumes, just show the decoded path
return decoded;
} catch (_) {
return path;
}
}
@override
void initState() {
super.initState();
@@ -33,19 +54,19 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final androidInfo = await deviceInfo.androidInfo;
final sdkVersion = androidInfo.version.sdkInt;
// Check appropriate storage permission based on Android version
bool hasPermission;
if (sdkVersion >= 30) {
hasPermission = await Permission.manageExternalStorage.isGranted;
} else {
hasPermission = await Permission.storage.isGranted;
}
if (mounted) {
setState(() {
_androidSdkVersion = sdkVersion;
_hasStoragePermission = hasPermission;
// SAF doesn't need storage permission on Android 10+
_hasStoragePermission = sdkVersion >= 29 ? true : false;
});
// For older Android, check legacy storage permission
if (sdkVersion < 29) {
final hasPermission = await Permission.storage.isGranted;
if (mounted) {
setState(() => _hasStoragePermission = hasPermission);
}
}
}
} else if (Platform.isIOS) {
// iOS doesn't need explicit storage permission for app documents
@@ -55,13 +76,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
Future<bool> _requestStoragePermission() async {
if (Platform.isIOS) return true;
// SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE
if (_androidSdkVersion >= 29) return true;
PermissionStatus status;
if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.request();
} else {
status = await Permission.storage.request();
}
final status = await Permission.storage.request();
if (status.isGranted) {
setState(() => _hasStoragePermission = true);
@@ -94,19 +112,30 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
}
Future<void> _pickLibraryFolder() async {
// Request permission first
if (!_hasStoragePermission) {
final granted = await _requestStoragePermission();
if (!granted) return;
}
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
if (Platform.isAndroid && _androidSdkVersion >= 29) {
// Use SAF tree picker - no MANAGE_EXTERNAL_STORAGE needed
final result = await PlatformBridge.pickSafTree();
if (result != null) {
final treeUri = result['tree_uri'] as String? ?? '';
if (treeUri.isNotEmpty) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(treeUri);
}
}
} else {
// Legacy: request permission and use file picker for older Android / iOS
if (!_hasStoragePermission) {
final granted = await _requestStoragePermission();
if (!granted) return;
}
// Fallback for older devices
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
}
}
Future<void> _startScan() async {
Future<void> _startScan({bool forceFullScan = false}) async {
final settings = ref.read(settingsProvider);
final libraryPath = settings.localLibraryPath;
@@ -127,7 +156,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
await ref.read(localLibraryProvider.notifier).startScan(libraryPath);
await ref.read(localLibraryProvider.notifier).startScan(
libraryPath,
forceFullScan: forceFullScan,
);
}
Future<void> _cancelScan() async {
@@ -232,6 +264,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile,
scanTotalFiles: libraryState.scanTotalFiles,
scannedFiles: libraryState.scannedFiles,
lastScannedAt: libraryState.lastScannedAt,
),
),
@@ -263,7 +296,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
title: context.l10n.libraryFolder,
subtitle: settings.localLibraryPath.isEmpty
? context.l10n.libraryFolderHint
: settings.localLibraryPath,
: _getDisplayPath(settings.localLibraryPath),
onTap: settings.localLibraryEnabled
? _pickLibraryFolder
: null,
@@ -348,7 +381,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
totalFiles: libraryState.scanTotalFiles,
onCancel: _cancelScan,
)
else
else ...[
Opacity(
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
@@ -362,6 +395,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
: null,
),
),
Opacity(
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.sync,
title: context.l10n.libraryForceFullScan,
subtitle: context.l10n.libraryForceFullScanSubtitle,
onTap: settings.localLibraryPath.isNotEmpty
? () => _startScan(forceFullScan: true)
: null,
),
),
],
Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
@@ -452,6 +497,7 @@ class _LibraryHeroCard extends StatelessWidget {
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
final int scannedFiles;
final DateTime? lastScannedAt;
const _LibraryHeroCard({
@@ -460,6 +506,7 @@ class _LibraryHeroCard extends StatelessWidget {
required this.scanProgress,
this.scanCurrentFile,
required this.scanTotalFiles,
required this.scannedFiles,
this.lastScannedAt,
});
@@ -584,7 +631,7 @@ class _LibraryHeroCard extends StatelessWidget {
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
itemCount.toString(),
isScanning ? scannedFiles.toString() : itemCount.toString(),
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
@@ -596,10 +643,12 @@ class _LibraryHeroCard extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
context.l10n
.libraryTracksCount(itemCount)
.replaceAll(itemCount.toString(), '')
.trim(), // Getting just the label part if possible, or just use the full string if not
isScanning
? context.l10n.libraryTracksCount(scannedFiles).replaceAll(scannedFiles.toString(), '').trim()
: context.l10n
.libraryTracksCount(itemCount)
.replaceAll(itemCount.toString(), '')
.trim(),
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurfaceVariant,
@@ -222,6 +222,12 @@ class OptionsSettingsPage extends ConsumerWidget {
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.cleanupOrphanedDownloads,
subtitle: context.l10n.cleanupOrphanedDownloadsSubtitle,
onTap: () => _cleanupOrphanedDownloads(context, ref),
),
SettingsItem(
icon: Icons.delete_forever,
title: context.l10n.optionsClearHistory,
@@ -294,6 +300,52 @@ class OptionsSettingsPage extends ConsumerWidget {
);
}
Future<void> _cleanupOrphanedDownloads(
BuildContext context,
WidgetRef ref,
) async {
// Show loading indicator
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
content: Row(
children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Text(context.l10n.cleanupOrphanedDownloads),
],
),
),
);
try {
final removed = await ref
.read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads();
if (context.mounted) {
Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
removed > 0
? context.l10n.cleanupOrphanedDownloadsResult(removed)
: context.l10n.cleanupOrphanedDownloadsNone,
),
),
);
}
} catch (e) {
if (context.mounted) {
Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
void _showSpotifyCredentialsDialog(
BuildContext context,
WidgetRef ref,
+7
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/screens/settings/extensions_page.dart';
import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/donate_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -91,6 +92,12 @@ class SettingsTab extends ConsumerWidget {
title: l10n.settingsExtensions,
subtitle: l10n.settingsExtensionsSubtitle,
onTap: () => _navigateTo(context, const ExtensionsPage()),
),
SettingsItem(
icon: Icons.favorite_outline,
title: 'Donate',
subtitle: 'Support SpotiFLAC-Mobile development',
onTap: () => _navigateTo(context, const DonatePage()),
showDivider: false,
),
],
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -1273,23 +1273,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
final shareTitle = '$trackName - $artistName';
// For SAF content URIs, use native share intent directly (zero-copy)
if (isContentUri(sharePath)) {
final tempPath = await PlatformBridge.copyContentUriToTemp(sharePath);
if (tempPath == null || tempPath.isEmpty) {
try {
await PlatformBridge.shareContentUri(sharePath, title: shareTitle);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('Failed to prepare file for sharing'))),
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('Failed to share file'))),
);
}
return;
}
sharePath = tempPath;
return;
}
await SharePlus.instance.share(
ShareParams(
files: [XFile(sharePath)],
text: '$trackName - $artistName',
text: shareTitle,
),
);
}
+712
View File
@@ -0,0 +1,712 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
class TutorialScreen extends ConsumerStatefulWidget {
const TutorialScreen({super.key});
@override
ConsumerState<TutorialScreen> createState() => _TutorialScreenState();
}
class _TutorialScreenState extends ConsumerState<TutorialScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
static const int _totalPages = 6;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _nextPage() {
if (_currentPage < _totalPages - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutQuart,
);
} else {
_completeTutorial();
}
}
void _prevPage() {
_pageController.previousPage(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutQuart,
);
}
void _completeTutorial() {
ref.read(settingsProvider.notifier).setTutorialComplete();
context.go('/');
}
void _skipTutorial() {
ref.read(settingsProvider.notifier).setTutorialComplete();
context.go('/');
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final l10n = context.l10n;
final isLastPage = _currentPage == _totalPages - 1;
return Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Column(
children: [
// Top Navigation Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: _currentPage > 0 ? 1.0 : 0.0,
child: IconButton.filledTonal(
onPressed: _currentPage > 0 ? _prevPage : null,
icon: const Icon(Icons.arrow_back),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
),
// Skip button
TextButton(
onPressed: _skipTutorial,
style: TextButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
child: Text(
l10n.setupSkip,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
),
// Main Content Area
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: (page) => setState(() => _currentPage = page),
children: [
_TutorialPage(
index: 0,
currentIndex: _currentPage,
icon: Icons.waving_hand_rounded,
iconColor: Colors.amber,
title: l10n.tutorialWelcomeTitle,
description: l10n.tutorialWelcomeDesc,
content: _buildFeatureList(context, [
(Icons.music_note_rounded, l10n.tutorialWelcomeTip1),
(Icons.high_quality_rounded, l10n.tutorialWelcomeTip2),
(Icons.download_rounded, l10n.tutorialWelcomeTip3),
]),
),
_TutorialPage(
index: 1,
currentIndex: _currentPage,
icon: Icons.search_rounded,
title: l10n.tutorialSearchTitle,
description: l10n.tutorialSearchDesc,
content: const _InteractiveSearchExample(),
),
_TutorialPage(
index: 2,
currentIndex: _currentPage,
icon: Icons.download_rounded,
title: l10n.tutorialDownloadTitle,
description: l10n.tutorialDownloadDesc,
content: const _InteractiveDownloadExample(),
),
_TutorialPage(
index: 3,
currentIndex: _currentPage,
icon: Icons.library_music_rounded,
title: l10n.tutorialLibraryTitle,
description: l10n.tutorialLibraryDesc,
content: _buildFeatureList(context, [
(Icons.offline_pin_rounded, l10n.tutorialLibraryTip1),
(Icons.play_circle_fill, l10n.tutorialLibraryTip2),
(Icons.grid_view_rounded, l10n.tutorialLibraryTip3),
]),
),
_TutorialPage(
index: 4,
currentIndex: _currentPage,
icon: Icons.extension_rounded,
title: l10n.tutorialExtensionsTitle,
description: l10n.tutorialExtensionsDesc,
content: _buildFeatureList(context, [
(Icons.storefront_rounded, l10n.tutorialExtensionsTip1),
(
Icons.add_circle_outline_rounded,
l10n.tutorialExtensionsTip2,
),
(Icons.lyrics_rounded, l10n.tutorialExtensionsTip3),
]),
),
_TutorialPage(
index: 5,
currentIndex: _currentPage,
icon: Icons.settings_rounded,
title: l10n.tutorialSettingsTitle,
description: l10n.tutorialSettingsDesc,
content: Column(
children: [
_buildFeatureList(context, [
(
Icons.folder_open_rounded,
l10n.tutorialSettingsTip1,
),
(Icons.tune_rounded, l10n.tutorialSettingsTip2),
(Icons.palette_rounded, l10n.tutorialSettingsTip3),
]),
const SizedBox(height: 24),
_AnimatedReadyCard(text: l10n.tutorialReadyMessage),
],
),
),
],
),
),
// Bottom Control Area
Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Expressive Page Indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_totalPages, (index) {
final isActive = _currentPage == index;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack,
margin: const EdgeInsets.symmetric(horizontal: 4),
height: 8,
width: isActive ? 32 : 8,
decoration: BoxDecoration(
color: isActive
? colorScheme.primary
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
);
}),
),
const SizedBox(height: 32),
// Action Button
SizedBox(
width: double.infinity,
height: 56,
child: FilledButton(
onPressed: _nextPage,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
child: Text(
isLastPage ? l10n.setupGetStarted : l10n.setupNext,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildFeatureList(
BuildContext context,
List<(IconData, String)> features,
) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: features.asMap().entries.map((entry) {
final index = entry.key;
final feature = entry.value;
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 600 + (index * 200)),
curve: Curves.easeOutQuart,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: Opacity(
opacity: value,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
feature.$1,
size: 24,
color: colorScheme.primary,
),
),
const SizedBox(width: 20),
Expanded(
child: Text(
feature.$2,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
},
);
}).toList(),
);
}
}
class _AnimatedReadyCard extends StatelessWidget {
final String text;
const _AnimatedReadyCard({required this.text});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(28),
),
child: Row(
children: [
Icon(
Icons.lightbulb_rounded,
color: colorScheme.onPrimaryContainer,
size: 28,
),
const SizedBox(width: 16),
Expanded(
child: Text(
text,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: colorScheme.onPrimaryContainer,
),
),
),
],
),
);
}
}
class _InteractiveSearchExample extends StatefulWidget {
const _InteractiveSearchExample();
@override
State<_InteractiveSearchExample> createState() =>
_InteractiveSearchExampleState();
}
class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> {
final TextEditingController _controller = TextEditingController();
bool _showResult = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search Input
TextField(
controller: _controller,
onChanged: (value) {
setState(() {
_showResult = value.isNotEmpty;
});
},
style: TextStyle(color: colorScheme.onSurface, fontSize: 16),
decoration: InputDecoration(
hintText: 'Paste or search...',
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
prefixIcon: Icon(Icons.search, color: colorScheme.primary),
filled: true,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
),
// Result Placeholder
AnimatedSize(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutBack,
child: _showResult
? Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.music_note_rounded,
color: colorScheme.onPrimaryContainer,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface.withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(7),
),
),
const SizedBox(height: 8),
Container(
width: 100,
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 12),
Icon(
Icons.download_rounded,
color: colorScheme.primary,
),
],
),
),
)
: const SizedBox.shrink(),
),
],
),
);
}
}
class _InteractiveDownloadExample extends StatefulWidget {
const _InteractiveDownloadExample();
@override
State<_InteractiveDownloadExample> createState() =>
_InteractiveDownloadExampleState();
}
class _InteractiveDownloadExampleState
extends State<_InteractiveDownloadExample> {
bool _isDownloading = false;
double _progress = 0.0;
bool _isCompleted = false;
void _startDownload() async {
if (_isDownloading || _isCompleted) return;
setState(() {
_isDownloading = true;
_progress = 0.0;
});
for (int i = 0; i <= 100; i += 5) {
if (!mounted) return;
await Future.delayed(const Duration(milliseconds: 50));
setState(() => _progress = i / 100);
}
setState(() {
_isDownloading = false;
_isCompleted = true;
});
// Reset after a delay
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() {
_isCompleted = false;
_progress = 0.0;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Row(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.album_rounded,
size: 36,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 140,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(7),
),
),
const SizedBox(height: 10),
if (_isDownloading)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: _progress,
minHeight: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
)
else
Container(
width: 90,
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _isCompleted ? Colors.green : colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (_isCompleted ? Colors.green : colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: _isDownloading
? SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.onPrimary,
),
)
: Icon(
_isCompleted
? Icons.check_rounded
: Icons.download_rounded,
color: colorScheme.onPrimary,
size: 28,
),
),
),
],
),
);
}
}
class _TutorialPage extends StatelessWidget {
final int index;
final int currentIndex;
final IconData icon;
final String title;
final String description;
final Widget content;
final Color? iconColor;
const _TutorialPage({
required this.index,
required this.currentIndex,
required this.icon,
required this.title,
required this.description,
required this.content,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Parallax effect logic (simplified for StatelessWidget)
// In a real advanced implementation we'd pass the Controller's listenable
// But for now, let's use entrance animations based on currentIndex == index
final isActive = currentIndex == index;
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
physics: const BouncingScrollPhysics(),
child: Column(
children: [
const SizedBox(height: 24),
AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack,
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 56,
color: iconColor ?? colorScheme.primary,
),
),
const SizedBox(height: 48),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
curve: Curves.easeOut,
child: Text(
title,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
letterSpacing: -0.5,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
curve: Curves.easeOut,
child: Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
fontSize: 16,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 56),
content, // The content itself now handles its own internal animations
const SizedBox(height: 32),
],
),
);
}
}
+37 -1
View File
@@ -450,10 +450,46 @@ class HistoryDatabase {
return null;
}
/// Close database
/// Close database
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
/// Get all file paths from download history
/// Used to exclude downloaded files from local library scan
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""'
);
return rows.map((r) => r['file_path'] as String).toSet();
}
/// Get all entries with file paths for orphan detection
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
final db = await database;
final rows = await db.rawQuery('''
SELECT id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name
FROM history
WHERE file_path IS NOT NULL AND file_path != ""
''');
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Delete multiple entries by IDs
Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0;
final db = await database;
final placeholders = List.filled(ids.length, '?').join(',');
final count = await db.rawDelete(
'DELETE FROM history WHERE id IN ($placeholders)',
ids,
);
_log.i('Deleted $count orphaned entries');
return count;
}
}
+68 -1
View File
@@ -15,6 +15,7 @@ class LocalLibraryItem {
final String filePath;
final String? coverPath;
final DateTime scannedAt;
final int? fileModTime;
final String? isrc;
final int? trackNumber;
final int? discNumber;
@@ -34,6 +35,7 @@ class LocalLibraryItem {
required this.filePath,
this.coverPath,
required this.scannedAt,
this.fileModTime,
this.isrc,
this.trackNumber,
this.discNumber,
@@ -54,6 +56,7 @@ class LocalLibraryItem {
'filePath': filePath,
'coverPath': coverPath,
'scannedAt': scannedAt.toIso8601String(),
'fileModTime': fileModTime,
'isrc': isrc,
'trackNumber': trackNumber,
'discNumber': discNumber,
@@ -75,6 +78,7 @@ class LocalLibraryItem {
filePath: json['filePath'] as String,
coverPath: json['coverPath'] as String?,
scannedAt: DateTime.parse(json['scannedAt'] as String),
fileModTime: (json['fileModTime'] as num?)?.toInt(),
isrc: json['isrc'] as String?,
trackNumber: json['trackNumber'] as int?,
discNumber: json['discNumber'] as int?,
@@ -111,7 +115,7 @@ class LibraryDatabase {
return await openDatabase(
path,
version: 2, // Bumped version for cover_path migration
version: 3, // Bumped version for file_mod_time migration
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -130,6 +134,7 @@ class LibraryDatabase {
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
scanned_at TEXT NOT NULL,
file_mod_time INTEGER,
isrc TEXT,
track_number INTEGER,
disc_number INTEGER,
@@ -158,6 +163,12 @@ class LibraryDatabase {
await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT');
_log.i('Added cover_path column');
}
if (oldVersion < 3) {
// Add file_mod_time column for incremental scanning
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
_log.i('Added file_mod_time column for incremental scanning');
}
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
@@ -170,6 +181,7 @@ class LibraryDatabase {
'file_path': json['filePath'],
'cover_path': json['coverPath'],
'scanned_at': json['scannedAt'],
'file_mod_time': json['fileModTime'],
'isrc': json['isrc'],
'track_number': json['trackNumber'],
'disc_number': json['discNumber'],
@@ -192,6 +204,7 @@ class LibraryDatabase {
'filePath': row['file_path'],
'coverPath': row['cover_path'],
'scannedAt': row['scanned_at'],
'fileModTime': row['file_mod_time'],
'isrc': row['isrc'],
'trackNumber': row['track_number'],
'discNumber': row['disc_number'],
@@ -383,4 +396,58 @@ class LibraryDatabase {
await db.close();
_database = null;
}
/// Get all file paths with their modification times for incremental scanning
/// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds)
Future<Map<String, int>> getFileModTimes() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library'
);
final result = <String, int>{};
for (final row in rows) {
final path = row['file_path'] as String;
final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0;
result[path] = modTime;
}
return result;
}
/// Update file_mod_time for existing rows using file_path as key.
Future<void> updateFileModTimes(Map<String, int> fileModTimes) async {
if (fileModTimes.isEmpty) return;
final db = await database;
final batch = db.batch();
for (final entry in fileModTimes.entries) {
batch.update(
'library',
{'file_mod_time': entry.value},
where: 'file_path = ?',
whereArgs: [entry.key],
);
}
await batch.commit(noResult: true);
}
/// Get all file paths in the library (for detecting deleted files)
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery('SELECT file_path FROM library');
return rows.map((r) => r['file_path'] as String).toSet();
}
/// Delete multiple items by their file paths
Future<int> deleteByPaths(List<String> filePaths) async {
if (filePaths.isEmpty) return 0;
final db = await database;
final placeholders = List.filled(filePaths.length, '?').join(',');
final result = await db.rawDelete(
'DELETE FROM library WHERE file_path IN ($placeholders)',
filePaths,
);
if (result > 0) {
_log.i('Deleted $result items from library');
}
return result;
}
}
File diff suppressed because it is too large Load Diff
+331
View File
@@ -0,0 +1,331 @@
import 'package:flutter/material.dart';
/// Custom painted icons for donate platforms
class KofiIcon extends StatelessWidget {
final double size;
final Color color;
const KofiIcon({super.key, this.size = 22, this.color = Colors.white});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _KofiPainter(color),
);
}
}
class _KofiPainter extends CustomPainter {
final Color color;
_KofiPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final s = size.width;
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
// Cup body
final cup = RRect.fromRectAndRadius(
Rect.fromLTWH(s * 0.08, s * 0.28, s * 0.62, s * 0.52),
Radius.circular(s * 0.12),
);
canvas.drawRRect(cup, paint);
// Handle
final handlePaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = s * 0.08
..strokeCap = StrokeCap.round;
final handlePath = Path()
..moveTo(s * 0.70, s * 0.40)
..quadraticBezierTo(s * 0.92, s * 0.40, s * 0.92, s * 0.54)
..quadraticBezierTo(s * 0.92, s * 0.68, s * 0.70, s * 0.68);
canvas.drawPath(handlePath, handlePaint);
// Heart on cup
final heartPaint = Paint()
..color = const Color(0xFFFF5E5B)
..style = PaintingStyle.fill;
final hx = s * 0.39;
final hy = s * 0.46;
final hs = s * 0.12;
final heart = Path()
..moveTo(hx, hy + hs * 0.3)
..cubicTo(hx - hs, hy - hs * 0.3, hx - hs * 0.5, hy - hs, hx, hy - hs * 0.4)
..cubicTo(hx + hs * 0.5, hy - hs, hx + hs, hy - hs * 0.3, hx, hy + hs * 0.3)
..close();
canvas.drawPath(heart, heartPaint);
// Steam lines
final steamPaint = Paint()
..color = color.withValues(alpha: 0.6)
..style = PaintingStyle.stroke
..strokeWidth = s * 0.04
..strokeCap = StrokeCap.round;
for (var i = 0; i < 2; i++) {
final sx = s * (0.30 + i * 0.18);
final steam = Path()
..moveTo(sx, s * 0.24)
..quadraticBezierTo(sx - s * 0.04, s * 0.18, sx, s * 0.12);
canvas.drawPath(steam, steamPaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class BmacIcon extends StatelessWidget {
final double size;
final Color color;
const BmacIcon({super.key, this.size = 22, this.color = Colors.black87});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _BmacPainter(color),
);
}
}
class _BmacPainter extends CustomPainter {
final Color color;
_BmacPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final s = size.width;
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
// Cup body (slightly tapered)
final cup = Path()
..moveTo(s * 0.15, s * 0.35)
..lineTo(s * 0.20, s * 0.82)
..quadraticBezierTo(s * 0.20, s * 0.90, s * 0.28, s * 0.90)
..lineTo(s * 0.56, s * 0.90)
..quadraticBezierTo(s * 0.64, s * 0.90, s * 0.64, s * 0.82)
..lineTo(s * 0.69, s * 0.35)
..close();
canvas.drawPath(cup, paint);
// Cup rim
final rim = RRect.fromRectAndRadius(
Rect.fromLTWH(s * 0.10, s * 0.30, s * 0.64, s * 0.10),
Radius.circular(s * 0.05),
);
canvas.drawRRect(rim, paint);
// Handle
final handlePaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = s * 0.07
..strokeCap = StrokeCap.round;
final handle = Path()
..moveTo(s * 0.69, s * 0.42)
..quadraticBezierTo(s * 0.90, s * 0.42, s * 0.90, s * 0.56)
..quadraticBezierTo(s * 0.90, s * 0.70, s * 0.69, s * 0.70);
canvas.drawPath(handle, handlePaint);
// Steam
final steamPaint = Paint()
..color = color.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = s * 0.04
..strokeCap = StrokeCap.round;
for (var i = 0; i < 3; i++) {
final sx = s * (0.26 + i * 0.14);
final steam = Path()
..moveTo(sx, s * 0.26)
..quadraticBezierTo(sx + s * 0.03, s * 0.18, sx, s * 0.10);
canvas.drawPath(steam, steamPaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class GitHubIcon extends StatelessWidget {
final double size;
final Color color;
const GitHubIcon({super.key, this.size = 22, this.color = Colors.white});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _GitHubPainter(color),
);
}
}
class _GitHubPainter extends CustomPainter {
final Color color;
_GitHubPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final s = size.width;
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
// GitHub octocat silhouette (simplified mark)
// Based on the GitHub logo path, scaled to fit
final scale = s / 24.0;
final path = Path();
// Outer circle/head shape
path.moveTo(12 * scale, 0.5 * scale);
path.cubicTo(
5.37 * scale, 0.5 * scale,
0 * scale, 5.87 * scale,
0 * scale, 12.5 * scale,
);
path.cubicTo(
0 * scale, 17.78 * scale,
3.44 * scale, 22.27 * scale,
8.21 * scale, 23.85 * scale,
);
path.cubicTo(
8.81 * scale, 23.96 * scale,
9.02 * scale, 23.59 * scale,
9.02 * scale, 23.27 * scale,
);
path.cubicTo(
9.02 * scale, 22.98 * scale,
9.01 * scale, 22.01 * scale,
9.01 * scale, 21.01 * scale,
);
// Left arm
path.cubicTo(
5.67 * scale, 21.71 * scale,
4.97 * scale, 19.56 * scale,
4.97 * scale, 19.56 * scale,
);
path.cubicTo(
4.42 * scale, 18.22 * scale,
3.63 * scale, 17.85 * scale,
3.63 * scale, 17.85 * scale,
);
path.cubicTo(
2.55 * scale, 17.12 * scale,
3.71 * scale, 17.13 * scale,
3.71 * scale, 17.13 * scale,
);
path.cubicTo(
4.90 * scale, 17.22 * scale,
5.53 * scale, 18.36 * scale,
5.53 * scale, 18.36 * scale,
);
path.cubicTo(
6.58 * scale, 20.05 * scale,
8.36 * scale, 19.53 * scale,
9.05 * scale, 19.22 * scale,
);
path.cubicTo(
9.16 * scale, 18.45 * scale,
9.47 * scale, 17.93 * scale,
9.81 * scale, 17.63 * scale,
);
// Bottom
path.cubicTo(
7.15 * scale, 17.33 * scale,
4.34 * scale, 16.33 * scale,
4.34 * scale, 11.93 * scale,
);
path.cubicTo(
4.34 * scale, 10.68 * scale,
4.81 * scale, 9.66 * scale,
5.55 * scale, 8.86 * scale,
);
path.cubicTo(
5.43 * scale, 8.56 * scale,
5.02 * scale, 7.40 * scale,
5.67 * scale, 5.82 * scale,
);
path.cubicTo(
5.67 * scale, 5.82 * scale,
6.66 * scale, 5.50 * scale,
8.98 * scale, 6.99 * scale,
);
path.cubicTo(
9.94 * scale, 6.72 * scale,
10.98 * scale, 6.59 * scale,
12.0 * scale, 6.58 * scale,
);
path.cubicTo(
13.02 * scale, 6.59 * scale,
14.06 * scale, 6.72 * scale,
15.02 * scale, 6.99 * scale,
);
path.cubicTo(
17.34 * scale, 5.50 * scale,
18.33 * scale, 5.82 * scale,
18.33 * scale, 5.82 * scale,
);
path.cubicTo(
18.98 * scale, 7.40 * scale,
18.57 * scale, 8.56 * scale,
18.45 * scale, 8.86 * scale,
);
path.cubicTo(
19.19 * scale, 9.66 * scale,
19.66 * scale, 10.68 * scale,
19.66 * scale, 11.93 * scale,
);
path.cubicTo(
19.66 * scale, 16.34 * scale,
16.84 * scale, 17.32 * scale,
14.17 * scale, 17.62 * scale,
);
path.cubicTo(
14.59 * scale, 17.99 * scale,
14.97 * scale, 18.70 * scale,
14.97 * scale, 19.80 * scale,
);
path.cubicTo(
14.97 * scale, 21.40 * scale,
14.95 * scale, 22.67 * scale,
14.95 * scale, 23.27 * scale,
);
path.cubicTo(
14.95 * scale, 23.60 * scale,
15.16 * scale, 23.97 * scale,
15.77 * scale, 23.85 * scale,
);
path.cubicTo(
20.55 * scale, 22.26 * scale,
24.0 * scale, 17.78 * scale,
24.0 * scale, 12.5 * scale,
);
path.cubicTo(
24.0 * scale, 5.87 * scale,
18.63 * scale, 0.5 * scale,
12.0 * scale, 0.5 * scale,
);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.5.0-beta.1+73
version: 3.5.0+74
environment:
sdk: ^3.10.0