mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-03 21:28:09 +02:00
feat: add local library scanning with duplicate detection
- Add Go backend library scanner for FLAC, M4A, MP3, Opus, OGG files - Read metadata from file tags (ISRC, track name, artist, album, bit depth, sample rate) - Fallback to filename parsing when tags unavailable - Add SQLite database for O(1) duplicate lookups - Show 'In Library' badge on search results for existing tracks - Match by ISRC (exact) or track name + artist (fuzzy) - Add Library Settings page with scan, cleanup, and clear actions - Add 30+ localization strings for library feature
This commit is contained in:
@@ -4,6 +4,16 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **Local Library Scanning**: Scan your existing music collection to detect duplicates when downloading
|
||||
- Recursive folder scanning for audio files (FLAC, M4A, MP3, Opus, OGG)
|
||||
- Reads metadata from file tags (ISRC, track name, artist, album, bit depth, sample rate)
|
||||
- Fallback to filename parsing when tags unavailable ("Artist - Title" pattern)
|
||||
- SQLite database for fast O(1) duplicate lookups
|
||||
- Progress tracking with cancel option during scan
|
||||
- Cleanup missing files and clear library actions
|
||||
- **Duplicate Detection in Search Results**: "In Library" badge shows on tracks that exist in your local library
|
||||
- Matches by ISRC (exact match) or track name + artist (fuzzy match)
|
||||
- Toggle indicator visibility in Settings > Local Library
|
||||
- **Cloud Upload with WebDAV & SFTP**: Automatically upload downloaded files to your NAS or cloud storage
|
||||
- Full WebDAV support (Synology DSM, Nextcloud, QNAP, ownCloud)
|
||||
- Full SFTP support (any SSH server with SFTP enabled)
|
||||
@@ -11,11 +21,18 @@
|
||||
- Upload queue with progress tracking
|
||||
- Retry failed uploads and clear completed items
|
||||
- Recent uploads list in Cloud Save settings
|
||||
- **SFTP Host Key Security (TOFU)**: Verify host keys on connect and block mismatches
|
||||
- **Reset SFTP Host Keys**: Clear the saved host key for the current server or all servers
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Added `webdav_client: ^1.2.2` for WebDAV protocol support
|
||||
- Added `dartssh2: ^2.13.0` for SFTP protocol support
|
||||
- Added `flutter_secure_storage: ^9.2.2` for storing cloud passwords securely
|
||||
|
||||
### Changed
|
||||
|
||||
- Cloud upload passwords are now stored in secure storage instead of SharedPreferences
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -892,6 +892,33 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Local Library Scanning
|
||||
"scanLibraryFolder" -> {
|
||||
val folderPath = call.argument<String>("folder_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.scanLibraryFolderJSON(folderPath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getLibraryScanProgress" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getLibraryScanProgressJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"cancelLibraryScan" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cancelLibraryScanJSON()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"readAudioMetadata" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.readAudioMetadataJSON(filePath)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -2089,3 +2089,25 @@ func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
||||
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
||||
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||
}
|
||||
|
||||
// ==================== LOCAL LIBRARY SCANNING ====================
|
||||
|
||||
// ScanLibraryFolderJSON scans a folder for audio files and returns metadata
|
||||
func ScanLibraryFolderJSON(folderPath string) (string, error) {
|
||||
return ScanLibraryFolder(folderPath)
|
||||
}
|
||||
|
||||
// GetLibraryScanProgressJSON returns current scan progress
|
||||
func GetLibraryScanProgressJSON() string {
|
||||
return GetLibraryScanProgress()
|
||||
}
|
||||
|
||||
// CancelLibraryScanJSON cancels ongoing library scan
|
||||
func CancelLibraryScanJSON() {
|
||||
CancelLibraryScan()
|
||||
}
|
||||
|
||||
// ReadAudioMetadataJSON reads metadata from a single audio file
|
||||
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
||||
return ReadAudioMetadata(filePath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LibraryScanResult represents metadata from a scanned audio file
|
||||
type LibraryScanResult struct {
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||
FilePath string `json:"filePath"`
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryScanProgress reports progress during scan
|
||||
type LibraryScanProgress struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
ScannedFiles int `json:"scanned_files"`
|
||||
CurrentFile string `json:"current_file"`
|
||||
ErrorCount int `json:"error_count"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
IsComplete bool `json:"is_complete"`
|
||||
}
|
||||
|
||||
var (
|
||||
libraryScanProgress LibraryScanProgress
|
||||
libraryScanProgressMu sync.RWMutex
|
||||
libraryScanCancel chan struct{}
|
||||
libraryScanCancelMu sync.Mutex
|
||||
)
|
||||
|
||||
// supportedAudioFormats lists file extensions we can read metadata from
|
||||
var supportedAudioFormats = map[string]bool{
|
||||
".flac": true,
|
||||
".m4a": true,
|
||||
".mp3": true,
|
||||
".opus": true,
|
||||
".ogg": true,
|
||||
}
|
||||
|
||||
// ScanLibraryFolder scans a folder recursively for audio files and reads their metadata
|
||||
// Returns JSON array of LibraryScanResult
|
||||
func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
if folderPath == "" {
|
||||
return "[]", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
|
||||
// Check if folder exists
|
||||
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)
|
||||
}
|
||||
|
||||
// Reset progress
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Create cancel channel
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
}
|
||||
libraryScanCancel = make(chan struct{})
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
// First pass: count audio files
|
||||
var audioFiles []string
|
||||
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip errors, continue walking
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if supportedAudioFormats[ext] {
|
||||
audioFiles = append(audioFiles, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
totalFiles := len(audioFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
if totalFiles == 0 {
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgressMu.Unlock()
|
||||
return "[]", nil
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
|
||||
|
||||
// Second pass: read metadata from each file
|
||||
results := make([]LibraryScanResult, 0, totalFiles)
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
for i, filePath := range audioFiles {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return "[]", fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
// Update progress
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Read metadata
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
// Mark complete
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ErrorCount = errorCount
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount)
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("failed to marshal results: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// scanAudioFile reads metadata from a single audio file
|
||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
result := &LibraryScanResult{
|
||||
ID: generateLibraryID(filePath),
|
||||
FilePath: filePath,
|
||||
ScannedAt: scanTime,
|
||||
Format: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
// Try to read metadata based on format
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result)
|
||||
case ".m4a":
|
||||
return scanM4AFile(filePath, result)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result)
|
||||
case ".opus", ".ogg":
|
||||
// Opus files often use same container as Ogg Vorbis
|
||||
return scanOggFile(filePath, result)
|
||||
default:
|
||||
// Fallback: use filename as title
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
}
|
||||
|
||||
// scanFLACFile reads metadata from FLAC file
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
// Fallback to filename
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Genre = metadata.Genre
|
||||
|
||||
// Read audio quality
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have at least a title
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// scanM4AFile reads metadata from M4A/AAC file
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
// M4A metadata reading is limited, try audio quality at least
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
// Fallback to filename parsing
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
// scanMP3File reads metadata from MP3 file (ID3 tags)
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
// We don't have ID3 parsing in Go backend yet, use filename
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
// scanOggFile reads metadata from Ogg Vorbis/Opus file
|
||||
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
// Limited support, use filename
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
// scanFromFilename extracts title/artist from filename pattern
|
||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
|
||||
// Common patterns:
|
||||
// "Artist - Title"
|
||||
// "01 - Title"
|
||||
// "01. Title"
|
||||
// "Title"
|
||||
|
||||
// Try "Artist - Title" pattern
|
||||
parts := strings.SplitN(filename, " - ", 2)
|
||||
if len(parts) == 2 {
|
||||
// Check if first part looks like a track number
|
||||
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
|
||||
result.TrackName = parts[1]
|
||||
result.ArtistName = "Unknown Artist"
|
||||
} else {
|
||||
result.ArtistName = parts[0]
|
||||
result.TrackName = parts[1]
|
||||
}
|
||||
} else {
|
||||
// Try "01. Title" or "01 Title" pattern
|
||||
if len(filename) > 3 && isNumeric(filename[:2]) {
|
||||
// Skip track number
|
||||
title := strings.TrimLeft(filename[2:], " .-")
|
||||
result.TrackName = title
|
||||
} else {
|
||||
result.TrackName = filename
|
||||
}
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
|
||||
// Use parent folder as album name
|
||||
dir := filepath.Dir(filePath)
|
||||
result.AlbumName = filepath.Base(dir)
|
||||
if result.AlbumName == "." || result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isNumeric checks if string contains only digits
|
||||
func isNumeric(s string) bool {
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
// generateLibraryID creates a unique ID for a library item
|
||||
func generateLibraryID(filePath string) string {
|
||||
// Use file path hash as ID
|
||||
return fmt.Sprintf("lib_%x", hashString(filePath))
|
||||
}
|
||||
|
||||
// hashString creates a simple hash of a string
|
||||
func hashString(s string) uint32 {
|
||||
var hash uint32 = 5381
|
||||
for _, c := range s {
|
||||
hash = ((hash << 5) + hash) + uint32(c)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// GetLibraryScanProgress returns current scan progress
|
||||
func GetLibraryScanProgress() string {
|
||||
libraryScanProgressMu.RLock()
|
||||
defer libraryScanProgressMu.RUnlock()
|
||||
|
||||
jsonBytes, _ := json.Marshal(libraryScanProgress)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// CancelLibraryScan cancels ongoing library scan
|
||||
func CancelLibraryScan() {
|
||||
libraryScanCancelMu.Lock()
|
||||
defer libraryScanCancelMu.Unlock()
|
||||
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
libraryScanCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ReadAudioMetadata reads metadata from any supported audio file
|
||||
// Returns JSON with track info
|
||||
func ReadAudioMetadata(filePath string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
@@ -687,6 +687,29 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Local Library Scanning
|
||||
case "scanLibraryFolder":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let folderPath = args["folder_path"] as! String
|
||||
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getLibraryScanProgress":
|
||||
let response = GobackendGetLibraryScanProgressJSON()
|
||||
return response
|
||||
|
||||
case "cancelLibraryScan":
|
||||
GobackendCancelLibraryScanJSON()
|
||||
return nil
|
||||
|
||||
case "readAudioMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
@@ -3832,6 +3832,72 @@ abstract class AppLocalizations {
|
||||
/// **'Recent Uploads'**
|
||||
String get cloudSettingsRecentUploads;
|
||||
|
||||
/// Button/title to reset SFTP host key for current server
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset SFTP Host Key'**
|
||||
String get cloudSettingsResetSftpHostKey;
|
||||
|
||||
/// Button/title to reset all saved SFTP host keys
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset All SFTP Host Keys'**
|
||||
String get cloudSettingsResetAllSftpHostKeys;
|
||||
|
||||
/// Dialog message for resetting SFTP host key
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This will forget the saved host key for this server. The next connection will save a new key.'**
|
||||
String get cloudSettingsResetSftpHostKeyMessage;
|
||||
|
||||
/// Dialog message for resetting all SFTP host keys
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This will forget all saved SFTP host keys. Next connections will save new keys.'**
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage;
|
||||
|
||||
/// Dialog confirm button for reset action
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset'**
|
||||
String get cloudSettingsResetConfirm;
|
||||
|
||||
/// Dialog confirm button for reset all action
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset All'**
|
||||
String get cloudSettingsResetAllConfirm;
|
||||
|
||||
/// Validation message when server URL is missing
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Server URL is required'**
|
||||
String get cloudSettingsServerUrlRequired;
|
||||
|
||||
/// Snackbar after resetting SFTP host key
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SFTP host key reset. Connect again to save a new key.'**
|
||||
String get cloudSettingsResetSftpHostKeySuccess;
|
||||
|
||||
/// Snackbar when no host key exists for the current server
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No stored host key found for this server.'**
|
||||
String get cloudSettingsResetSftpHostKeyNotFound;
|
||||
|
||||
/// Snackbar after clearing all SFTP host keys
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Cleared 1 SFTP host key.} other{Cleared {count} SFTP host keys.}}'**
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count);
|
||||
|
||||
/// Snackbar when no SFTP host keys exist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No stored SFTP host keys found.'**
|
||||
String get cloudSettingsResetAllSftpHostKeysNone;
|
||||
|
||||
/// Empty queue state title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4185,6 +4251,210 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'All Files Access disabled. The app will use limited storage access.'**
|
||||
String get allFilesAccessDisabledMessage;
|
||||
|
||||
/// Settings menu item - local library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Local Library'**
|
||||
String get settingsLocalLibrary;
|
||||
|
||||
/// Subtitle for local library settings
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan music & detect duplicates'**
|
||||
String get settingsLocalLibrarySubtitle;
|
||||
|
||||
/// Library settings page title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Local Library'**
|
||||
String get libraryTitle;
|
||||
|
||||
/// Section header for library status
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Library Status'**
|
||||
String get libraryStatus;
|
||||
|
||||
/// Section header for scan settings
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan Settings'**
|
||||
String get libraryScanSettings;
|
||||
|
||||
/// Toggle to enable library scanning
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable Local Library'**
|
||||
String get libraryEnableLocalLibrary;
|
||||
|
||||
/// Subtitle for enable toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan and track your existing music'**
|
||||
String get libraryEnableLocalLibrarySubtitle;
|
||||
|
||||
/// Folder selection setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Library Folder'**
|
||||
String get libraryFolder;
|
||||
|
||||
/// Placeholder when no folder selected
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap to select folder'**
|
||||
String get libraryFolderHint;
|
||||
|
||||
/// Toggle for duplicate indicator in search
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show Duplicate Indicator'**
|
||||
String get libraryShowDuplicateIndicator;
|
||||
|
||||
/// Subtitle for duplicate indicator toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show when searching for existing tracks'**
|
||||
String get libraryShowDuplicateIndicatorSubtitle;
|
||||
|
||||
/// Section header for library actions
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Actions'**
|
||||
String get libraryActions;
|
||||
|
||||
/// Button to start library scan
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan Library'**
|
||||
String get libraryScan;
|
||||
|
||||
/// Subtitle for scan button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan for audio files'**
|
||||
String get libraryScanSubtitle;
|
||||
|
||||
/// Message when trying to scan without folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select a folder first'**
|
||||
String get libraryScanSelectFolderFirst;
|
||||
|
||||
/// Button to remove entries for missing files
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cleanup Missing Files'**
|
||||
String get libraryCleanupMissingFiles;
|
||||
|
||||
/// Subtitle for cleanup button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove entries for files that no longer exist'**
|
||||
String get libraryCleanupMissingFilesSubtitle;
|
||||
|
||||
/// Button to clear all library entries
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear Library'**
|
||||
String get libraryClear;
|
||||
|
||||
/// Subtitle for clear button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove all scanned tracks'**
|
||||
String get libraryClearSubtitle;
|
||||
|
||||
/// Dialog title for clear confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear Library'**
|
||||
String get libraryClearConfirmTitle;
|
||||
|
||||
/// Dialog message for clear confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This will remove all scanned tracks from your library. Your actual music files will not be deleted.'**
|
||||
String get libraryClearConfirmMessage;
|
||||
|
||||
/// Section header for about info
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About Local Library'**
|
||||
String get libraryAbout;
|
||||
|
||||
/// Description of local library feature
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'**
|
||||
String get libraryAboutDescription;
|
||||
|
||||
/// Track count in library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} tracks'**
|
||||
String libraryTracksCount(int count);
|
||||
|
||||
/// Last scan time display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last scanned: {time}'**
|
||||
String libraryLastScanned(String time);
|
||||
|
||||
/// Shown when library has never been scanned
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Never'**
|
||||
String get libraryLastScannedNever;
|
||||
|
||||
/// Status during scan
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scanning...'**
|
||||
String get libraryScanning;
|
||||
|
||||
/// Scan progress display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{progress}% of {total} files'**
|
||||
String libraryScanProgress(String progress, int total);
|
||||
|
||||
/// Badge shown on tracks that exist in local library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'In Library'**
|
||||
String get libraryInLibrary;
|
||||
|
||||
/// Snackbar after cleanup
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Removed {count} missing files from library'**
|
||||
String libraryRemovedMissingFiles(int count);
|
||||
|
||||
/// Snackbar after clearing library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Library cleared'**
|
||||
String get libraryCleared;
|
||||
|
||||
/// Dialog title for storage permission
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Storage Access Required'**
|
||||
String get libraryStorageAccessRequired;
|
||||
|
||||
/// Dialog message for storage permission
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.'**
|
||||
String get libraryStorageAccessMessage;
|
||||
|
||||
/// Error when folder doesn't exist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Selected folder does not exist'**
|
||||
String get libraryFolderNotExist;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -2101,6 +2101,52 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2323,4 +2369,120 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2086,6 +2086,52 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2308,4 +2354,120 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2086,6 +2086,52 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2308,6 +2354,122 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
|
||||
@@ -2086,6 +2086,52 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2308,4 +2354,120 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2086,6 +2086,52 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2308,4 +2354,120 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2099,6 +2099,52 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Tidak ada unduhan dalam antrian';
|
||||
|
||||
@@ -2321,4 +2367,120 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2073,6 +2073,52 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'キューにダウンロードがありません';
|
||||
|
||||
@@ -2294,4 +2340,120 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2086,6 +2086,52 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2308,4 +2354,120 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2086,6 +2086,52 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2308,4 +2354,120 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2086,6 +2086,52 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2308,6 +2354,122 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
|
||||
@@ -2125,6 +2125,52 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Нет загрузок в очереди';
|
||||
|
||||
@@ -2354,4 +2400,120 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2101,6 +2101,52 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2323,4 +2369,120 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
@@ -2086,6 +2086,52 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get cloudSettingsRecentUploads => 'Recent Uploads';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyMessage =>
|
||||
'This will forget the saved host key for this server. The next connection will save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysMessage =>
|
||||
'This will forget all saved SFTP host keys. Next connections will save new keys.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetConfirm => 'Reset';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllConfirm => 'Reset All';
|
||||
|
||||
@override
|
||||
String get cloudSettingsServerUrlRequired => 'Server URL is required';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeySuccess =>
|
||||
'SFTP host key reset. Connect again to save a new key.';
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetSftpHostKeyNotFound =>
|
||||
'No stored host key found for this server.';
|
||||
|
||||
@override
|
||||
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Cleared $count SFTP host keys.',
|
||||
one: 'Cleared 1 SFTP host key.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cloudSettingsResetAllSftpHostKeysNone =>
|
||||
'No stored SFTP host keys found.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No downloads in queue';
|
||||
|
||||
@@ -2308,6 +2354,122 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
|
||||
+113
-1
@@ -1527,6 +1527,28 @@
|
||||
"@cloudSettingsClearDone": {"description": "Button to clear completed uploads"},
|
||||
"cloudSettingsRecentUploads": "Recent Uploads",
|
||||
"@cloudSettingsRecentUploads": {"description": "Section header for recent uploads list"},
|
||||
"cloudSettingsResetSftpHostKey": "Reset SFTP Host Key",
|
||||
"@cloudSettingsResetSftpHostKey": {"description": "Button/title to reset SFTP host key for current server"},
|
||||
"cloudSettingsResetAllSftpHostKeys": "Reset All SFTP Host Keys",
|
||||
"@cloudSettingsResetAllSftpHostKeys": {"description": "Button/title to reset all saved SFTP host keys"},
|
||||
"cloudSettingsResetSftpHostKeyMessage": "This will forget the saved host key for this server. The next connection will save a new key.",
|
||||
"@cloudSettingsResetSftpHostKeyMessage": {"description": "Dialog message for resetting SFTP host key"},
|
||||
"cloudSettingsResetAllSftpHostKeysMessage": "This will forget all saved SFTP host keys. Next connections will save new keys.",
|
||||
"@cloudSettingsResetAllSftpHostKeysMessage": {"description": "Dialog message for resetting all SFTP host keys"},
|
||||
"cloudSettingsResetConfirm": "Reset",
|
||||
"@cloudSettingsResetConfirm": {"description": "Dialog confirm button for reset action"},
|
||||
"cloudSettingsResetAllConfirm": "Reset All",
|
||||
"@cloudSettingsResetAllConfirm": {"description": "Dialog confirm button for reset all action"},
|
||||
"cloudSettingsServerUrlRequired": "Server URL is required",
|
||||
"@cloudSettingsServerUrlRequired": {"description": "Validation message when server URL is missing"},
|
||||
"cloudSettingsResetSftpHostKeySuccess": "SFTP host key reset. Connect again to save a new key.",
|
||||
"@cloudSettingsResetSftpHostKeySuccess": {"description": "Snackbar after resetting SFTP host key"},
|
||||
"cloudSettingsResetSftpHostKeyNotFound": "No stored host key found for this server.",
|
||||
"@cloudSettingsResetSftpHostKeyNotFound": {"description": "Snackbar when no host key exists for the current server"},
|
||||
"cloudSettingsResetAllSftpHostKeysCleared": "{count, plural, =1{Cleared 1 SFTP host key.} other{Cleared {count} SFTP host keys.}}",
|
||||
"@cloudSettingsResetAllSftpHostKeysCleared": {"description": "Snackbar after clearing all SFTP host keys", "placeholders": {"count": {}}},
|
||||
"cloudSettingsResetAllSftpHostKeysNone": "No stored SFTP host keys found.",
|
||||
"@cloudSettingsResetAllSftpHostKeysNone": {"description": "Snackbar when no SFTP host keys exist"},
|
||||
|
||||
"queueEmpty": "No downloads in queue",
|
||||
"@queueEmpty": {"description": "Empty queue state title"},
|
||||
@@ -1727,5 +1749,95 @@
|
||||
"allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.",
|
||||
"@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"},
|
||||
"allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.",
|
||||
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"}
|
||||
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"},
|
||||
|
||||
"settingsLocalLibrary": "Local Library",
|
||||
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
|
||||
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
||||
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
|
||||
"libraryTitle": "Local Library",
|
||||
"@libraryTitle": {"description": "Library settings page title"},
|
||||
"libraryStatus": "Library Status",
|
||||
"@libraryStatus": {"description": "Section header for library status"},
|
||||
"libraryScanSettings": "Scan Settings",
|
||||
"@libraryScanSettings": {"description": "Section header for scan settings"},
|
||||
"libraryEnableLocalLibrary": "Enable Local Library",
|
||||
"@libraryEnableLocalLibrary": {"description": "Toggle to enable library scanning"},
|
||||
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
|
||||
"@libraryEnableLocalLibrarySubtitle": {"description": "Subtitle for enable toggle"},
|
||||
"libraryFolder": "Library Folder",
|
||||
"@libraryFolder": {"description": "Folder selection setting"},
|
||||
"libraryFolderHint": "Tap to select folder",
|
||||
"@libraryFolderHint": {"description": "Placeholder when no folder selected"},
|
||||
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
|
||||
"@libraryShowDuplicateIndicator": {"description": "Toggle for duplicate indicator in search"},
|
||||
"libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks",
|
||||
"@libraryShowDuplicateIndicatorSubtitle": {"description": "Subtitle for duplicate indicator toggle"},
|
||||
"libraryActions": "Actions",
|
||||
"@libraryActions": {"description": "Section header for library actions"},
|
||||
"libraryScan": "Scan Library",
|
||||
"@libraryScan": {"description": "Button to start library scan"},
|
||||
"libraryScanSubtitle": "Scan for audio files",
|
||||
"@libraryScanSubtitle": {"description": "Subtitle for scan button"},
|
||||
"libraryScanSelectFolderFirst": "Select a folder first",
|
||||
"@libraryScanSelectFolderFirst": {"description": "Message when trying to scan without folder"},
|
||||
"libraryCleanupMissingFiles": "Cleanup Missing Files",
|
||||
"@libraryCleanupMissingFiles": {"description": "Button to remove entries for missing files"},
|
||||
"libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist",
|
||||
"@libraryCleanupMissingFilesSubtitle": {"description": "Subtitle for cleanup button"},
|
||||
"libraryClear": "Clear Library",
|
||||
"@libraryClear": {"description": "Button to clear all library entries"},
|
||||
"libraryClearSubtitle": "Remove all scanned tracks",
|
||||
"@libraryClearSubtitle": {"description": "Subtitle for clear button"},
|
||||
"libraryClearConfirmTitle": "Clear Library",
|
||||
"@libraryClearConfirmTitle": {"description": "Dialog title for clear confirmation"},
|
||||
"libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.",
|
||||
"@libraryClearConfirmMessage": {"description": "Dialog message for clear confirmation"},
|
||||
"libraryAbout": "About Local Library",
|
||||
"@libraryAbout": {"description": "Section header for about info"},
|
||||
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
|
||||
"@libraryAboutDescription": {"description": "Description of local library feature"},
|
||||
"libraryTracksCount": "{count} tracks",
|
||||
"@libraryTracksCount": {
|
||||
"description": "Track count in library",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
"placeholders": {
|
||||
"time": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"libraryLastScannedNever": "Never",
|
||||
"@libraryLastScannedNever": {"description": "Shown when library has never been scanned"},
|
||||
"libraryScanning": "Scanning...",
|
||||
"@libraryScanning": {"description": "Status during scan"},
|
||||
"libraryScanProgress": "{progress}% of {total} files",
|
||||
"@libraryScanProgress": {
|
||||
"description": "Scan progress display",
|
||||
"placeholders": {
|
||||
"progress": {"type": "String"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryInLibrary": "In Library",
|
||||
"@libraryInLibrary": {"description": "Badge shown on tracks that exist in local library"},
|
||||
"libraryRemovedMissingFiles": "Removed {count} missing files from library",
|
||||
"@libraryRemovedMissingFiles": {
|
||||
"description": "Snackbar after cleanup",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryCleared": "Library cleared",
|
||||
"@libraryCleared": {"description": "Snackbar after clearing library"},
|
||||
"libraryStorageAccessRequired": "Storage Access Required",
|
||||
"@libraryStorageAccessRequired": {"description": "Dialog title for storage permission"},
|
||||
"libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.",
|
||||
"@libraryStorageAccessMessage": {"description": "Dialog message for storage permission"},
|
||||
"libraryFolderNotExist": "Selected folder does not exist",
|
||||
"@libraryFolderNotExist": {"description": "Error when folder doesn't exist"}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,13 @@ class AppSettings {
|
||||
final String cloudProvider; // 'none', 'webdav', 'sftp', 'gdrive'
|
||||
final String cloudServerUrl; // WebDAV/SFTP server URL
|
||||
final String cloudUsername; // Server username
|
||||
final String cloudPassword; // Server password (encrypted)
|
||||
final String cloudPassword; // Server password (stored securely)
|
||||
final String cloudRemotePath; // Remote folder path (e.g. /Music/SpotiFLAC)
|
||||
|
||||
// Local Library Settings
|
||||
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
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -85,6 +90,10 @@ class AppSettings {
|
||||
this.cloudUsername = '',
|
||||
this.cloudPassword = '',
|
||||
this.cloudRemotePath = '/Music/SpotiFLAC',
|
||||
// Local Library defaults
|
||||
this.localLibraryEnabled = false,
|
||||
this.localLibraryPath = '',
|
||||
this.localLibraryShowDuplicates = true,
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -128,6 +137,10 @@ class AppSettings {
|
||||
String? cloudUsername,
|
||||
String? cloudPassword,
|
||||
String? cloudRemotePath,
|
||||
// Local Library
|
||||
bool? localLibraryEnabled,
|
||||
String? localLibraryPath,
|
||||
bool? localLibraryShowDuplicates,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -169,6 +182,10 @@ class AppSettings {
|
||||
cloudUsername: cloudUsername ?? this.cloudUsername,
|
||||
cloudPassword: cloudPassword ?? this.cloudPassword,
|
||||
cloudRemotePath: cloudRemotePath ?? this.cloudRemotePath,
|
||||
// Local Library
|
||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
cloudUsername: json['cloudUsername'] as String? ?? '',
|
||||
cloudPassword: json['cloudPassword'] as String? ?? '',
|
||||
cloudRemotePath: json['cloudRemotePath'] as String? ?? '/Music/SpotiFLAC',
|
||||
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
|
||||
localLibraryPath: json['localLibraryPath'] as String? ?? '',
|
||||
localLibraryShowDuplicates:
|
||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -90,4 +94,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'cloudUsername': instance.cloudUsername,
|
||||
'cloudPassword': instance.cloudPassword,
|
||||
'cloudRemotePath': instance.cloudRemotePath,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('LocalLibrary');
|
||||
|
||||
/// State for local library
|
||||
class LocalLibraryState {
|
||||
final List<LocalLibraryItem> items;
|
||||
final bool isScanning;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
final int scanErrorCount;
|
||||
final DateTime? lastScannedAt;
|
||||
final Set<String> _isrcSet;
|
||||
final Set<String> _trackKeySet;
|
||||
final Map<String, LocalLibraryItem> _byIsrc;
|
||||
|
||||
LocalLibraryState({
|
||||
this.items = const [],
|
||||
this.isScanning = false,
|
||||
this.scanProgress = 0,
|
||||
this.scanCurrentFile,
|
||||
this.scanTotalFiles = 0,
|
||||
this.scanErrorCount = 0,
|
||||
this.lastScannedAt,
|
||||
}) : _isrcSet = items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => item.isrc!)
|
||||
.toSet(),
|
||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc = Map.fromEntries(
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
|
||||
/// Check if ISRC exists in library
|
||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
||||
|
||||
/// Check if track exists by name and artist
|
||||
bool hasTrack(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
return _trackKeySet.contains(key);
|
||||
}
|
||||
|
||||
/// Find library item by ISRC
|
||||
LocalLibraryItem? getByIsrc(String isrc) => _byIsrc[isrc];
|
||||
|
||||
/// Find library item by track name and artist
|
||||
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
return items.where((item) => item.matchKey == key).firstOrNull;
|
||||
}
|
||||
|
||||
/// Check if a track exists in library (by ISRC or name matching)
|
||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||
if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) {
|
||||
return true;
|
||||
}
|
||||
if (trackName != null && artistName != null) {
|
||||
return hasTrack(trackName, artistName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
LocalLibraryState copyWith({
|
||||
List<LocalLibraryItem>? items,
|
||||
bool? isScanning,
|
||||
double? scanProgress,
|
||||
String? scanCurrentFile,
|
||||
int? scanTotalFiles,
|
||||
int? scanErrorCount,
|
||||
DateTime? lastScannedAt,
|
||||
}) {
|
||||
return LocalLibraryState(
|
||||
items: items ?? this.items,
|
||||
isScanning: isScanning ?? this.isScanning,
|
||||
scanProgress: scanProgress ?? this.scanProgress,
|
||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
|
||||
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
|
||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for local library state management
|
||||
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||
Timer? _progressTimer;
|
||||
bool _isLoaded = false;
|
||||
|
||||
@override
|
||||
LocalLibraryState build() {
|
||||
ref.onDispose(() {
|
||||
_progressTimer?.cancel();
|
||||
});
|
||||
|
||||
Future.microtask(() async {
|
||||
await _loadFromDatabase();
|
||||
});
|
||||
return LocalLibraryState();
|
||||
}
|
||||
|
||||
Future<void> _loadFromDatabase() async {
|
||||
if (_isLoaded) return;
|
||||
_isLoaded = true;
|
||||
|
||||
try {
|
||||
final jsonList = await _db.getAll();
|
||||
final items = jsonList
|
||||
.map((e) => LocalLibraryItem.fromJson(e))
|
||||
.toList();
|
||||
|
||||
state = state.copyWith(items: items);
|
||||
_log.i('Loaded ${items.length} items from library database');
|
||||
} catch (e, stack) {
|
||||
_log.e('Failed to load library from database: $e', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload library from database
|
||||
Future<void> reloadFromStorage() async {
|
||||
_isLoaded = false;
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
|
||||
/// Start scanning a folder for audio files
|
||||
Future<void> startScan(String folderPath) async {
|
||||
if (state.isScanning) {
|
||||
_log.w('Scan already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
_log.i('Starting library scan: $folderPath');
|
||||
state = state.copyWith(
|
||||
isScanning: true,
|
||||
scanProgress: 0,
|
||||
scanCurrentFile: null,
|
||||
scanTotalFiles: 0,
|
||||
scanErrorCount: 0,
|
||||
);
|
||||
|
||||
// Start progress polling
|
||||
_startProgressPolling();
|
||||
|
||||
try {
|
||||
final results = await PlatformBridge.scanLibraryFolder(folderPath);
|
||||
|
||||
// Convert results to LocalLibraryItem and save to database
|
||||
final items = <LocalLibraryItem>[];
|
||||
for (final json in results) {
|
||||
final item = LocalLibraryItem.fromJson(json);
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
// Batch insert into database
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
|
||||
// Update state
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
_log.i('Scan complete: ${items.length} tracks found');
|
||||
} catch (e, stack) {
|
||||
_log.e('Library scan failed: $e', e, stack);
|
||||
state = state.copyWith(isScanning: false);
|
||||
} finally {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
}
|
||||
|
||||
void _startProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
try {
|
||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||
|
||||
state = state.copyWith(
|
||||
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
|
||||
scanCurrentFile: progress['current_file'] as String?,
|
||||
scanTotalFiles: progress['total_files'] as int? ?? 0,
|
||||
scanErrorCount: progress['error_count'] as int? ?? 0,
|
||||
);
|
||||
|
||||
if (progress['is_complete'] == true) {
|
||||
_stopProgressPolling();
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopProgressPolling() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
}
|
||||
|
||||
/// Cancel ongoing scan
|
||||
Future<void> cancelScan() async {
|
||||
if (!state.isScanning) return;
|
||||
|
||||
_log.i('Cancelling library scan');
|
||||
await PlatformBridge.cancelLibraryScan();
|
||||
state = state.copyWith(isScanning: false);
|
||||
_stopProgressPolling();
|
||||
}
|
||||
|
||||
/// Clean up missing files from library
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final removed = await _db.cleanupMissingFiles();
|
||||
if (removed > 0) {
|
||||
await reloadFromStorage();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// Clear all library data
|
||||
Future<void> clearLibrary() async {
|
||||
await _db.clearAll();
|
||||
state = LocalLibraryState();
|
||||
_log.i('Library cleared');
|
||||
}
|
||||
|
||||
/// Check if a track exists in library
|
||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||
return state.existsInLibrary(
|
||||
isrc: isrc,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get library item by ISRC
|
||||
LocalLibraryItem? getByIsrc(String isrc) {
|
||||
return state.getByIsrc(isrc);
|
||||
}
|
||||
|
||||
/// Find library item for a track
|
||||
LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) {
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = state.getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
if (trackName != null && artistName != null) {
|
||||
return state.findByTrackAndArtist(trackName, artistName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Search library
|
||||
Future<List<LocalLibraryItem>> search(String query) async {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
final results = await _db.search(query);
|
||||
return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
/// Get library count
|
||||
Future<int> getCount() async {
|
||||
return await _db.getCount();
|
||||
}
|
||||
}
|
||||
|
||||
final localLibraryProvider =
|
||||
NotifierProvider<LocalLibraryNotifier, LocalLibraryState>(
|
||||
LocalLibraryNotifier.new,
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -8,9 +9,11 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 1;
|
||||
const _cloudPasswordKey = 'cloud_password';
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
AppSettings build() {
|
||||
@@ -25,11 +28,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
await _runMigrations(prefs);
|
||||
|
||||
_applySpotifyCredentials();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
|
||||
await _loadCloudPassword(prefs);
|
||||
|
||||
_applySpotifyCredentials();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
@@ -49,7 +54,38 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||
final settingsToSave = state.copyWith(cloudPassword: '');
|
||||
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson()));
|
||||
}
|
||||
|
||||
Future<void> _loadCloudPassword(SharedPreferences prefs) async {
|
||||
final storedPassword = await _secureStorage.read(key: _cloudPasswordKey);
|
||||
final prefsPassword = state.cloudPassword;
|
||||
|
||||
if ((storedPassword == null || storedPassword.isEmpty) &&
|
||||
prefsPassword.isNotEmpty) {
|
||||
await _secureStorage.write(key: _cloudPasswordKey, value: prefsPassword);
|
||||
}
|
||||
|
||||
final effectivePassword = (storedPassword != null && storedPassword.isNotEmpty)
|
||||
? storedPassword
|
||||
: (prefsPassword.isNotEmpty ? prefsPassword : '');
|
||||
|
||||
if (effectivePassword != state.cloudPassword) {
|
||||
state = state.copyWith(cloudPassword: effectivePassword);
|
||||
}
|
||||
|
||||
if (prefsPassword.isNotEmpty) {
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _storeCloudPassword(String password) async {
|
||||
if (password.isEmpty) {
|
||||
await _secureStorage.delete(key: _cloudPasswordKey);
|
||||
} else {
|
||||
await _secureStorage.write(key: _cloudPasswordKey, value: password);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
@@ -272,8 +308,9 @@ void setUseAllFilesAccess(bool enabled) {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setCloudPassword(String password) {
|
||||
Future<void> setCloudPassword(String password) async {
|
||||
state = state.copyWith(cloudPassword: password);
|
||||
await _storeCloudPassword(password);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
@@ -282,22 +319,42 @@ void setUseAllFilesAccess(bool enabled) {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setCloudSettings({
|
||||
Future<void> setCloudSettings({
|
||||
bool? enabled,
|
||||
String? provider,
|
||||
String? serverUrl,
|
||||
String? username,
|
||||
String? password,
|
||||
String? remotePath,
|
||||
}) {
|
||||
}) async {
|
||||
final nextPassword = password ?? state.cloudPassword;
|
||||
state = state.copyWith(
|
||||
cloudUploadEnabled: enabled ?? state.cloudUploadEnabled,
|
||||
cloudProvider: provider ?? state.cloudProvider,
|
||||
cloudServerUrl: serverUrl ?? state.cloudServerUrl,
|
||||
cloudUsername: username ?? state.cloudUsername,
|
||||
cloudPassword: password ?? state.cloudPassword,
|
||||
cloudPassword: nextPassword,
|
||||
cloudRemotePath: remotePath ?? state.cloudRemotePath,
|
||||
);
|
||||
if (password != null) {
|
||||
await _storeCloudPassword(nextPassword);
|
||||
}
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
// Local Library Settings
|
||||
void setLocalLibraryEnabled(bool enabled) {
|
||||
state = state.copyWith(localLibraryEnabled: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryPath(String path) {
|
||||
state = state.copyWith(localLibraryPath: path);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryShowDuplicates(bool show) {
|
||||
state = state.copyWith(localLibraryShowDuplicates: show);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/providers/explore_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
@@ -2417,6 +2418,18 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
// Check local library for duplicate detection
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
|
||||
final isInLocalLibrary = showLocalLibraryIndicator
|
||||
? ref.watch(localLibraryProvider.select((state) =>
|
||||
state.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
)))
|
||||
: false;
|
||||
|
||||
double thumbWidth = 56;
|
||||
double thumbHeight = 56;
|
||||
|
||||
@@ -2490,11 +2503,46 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
track.artistName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
track.artistName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (isInLocalLibrary) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
context.l10n.libraryInLibrary,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
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/widgets/settings_group.dart';
|
||||
|
||||
class LibrarySettingsPage extends ConsumerStatefulWidget {
|
||||
const LibrarySettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LibrarySettingsPage> createState() => _LibrarySettingsPageState();
|
||||
}
|
||||
|
||||
class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasStoragePermission = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initDeviceInfo();
|
||||
}
|
||||
|
||||
Future<void> _initDeviceInfo() async {
|
||||
if (Platform.isAndroid) {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
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;
|
||||
});
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS doesn't need explicit storage permission for app documents
|
||||
setState(() => _hasStoragePermission = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _requestStoragePermission() async {
|
||||
if (Platform.isIOS) return true;
|
||||
|
||||
PermissionStatus status;
|
||||
if (_androidSdkVersion >= 30) {
|
||||
status = await Permission.manageExternalStorage.request();
|
||||
} else {
|
||||
status = await Permission.storage.request();
|
||||
}
|
||||
|
||||
if (status.isGranted) {
|
||||
setState(() => _hasStoragePermission = true);
|
||||
return true;
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
if (mounted) {
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.libraryStorageAccessRequired),
|
||||
content: Text(context.l10n.libraryStorageAccessMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.setupOpenSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (shouldOpen == true) {
|
||||
await openAppSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final libraryPath = settings.localLibraryPath;
|
||||
|
||||
if (libraryPath.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.libraryScanSelectFolderFirst)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if folder exists
|
||||
if (!await Directory(libraryPath).exists()) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.libraryFolderNotExist)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(localLibraryProvider.notifier).startScan(libraryPath);
|
||||
}
|
||||
|
||||
Future<void> _cancelScan() async {
|
||||
await ref.read(localLibraryProvider.notifier).cancelScan();
|
||||
}
|
||||
|
||||
Future<void> _clearLibrary() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.libraryClearConfirmTitle),
|
||||
content: Text(context.l10n.libraryClearConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(context.l10n.dialogClear),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref.read(localLibraryProvider.notifier).clearLibrary();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.libraryCleared)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupMissingFiles() async {
|
||||
final removed = await ref.read(localLibraryProvider.notifier).cleanupMissingFiles();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.libraryRemovedMissingFiles(removed))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final libraryState = ref.watch(localLibraryProvider);
|
||||
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(
|
||||
context.l10n.libraryTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Library Status Section
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.libraryStatus),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_LibraryStatusCard(
|
||||
itemCount: libraryState.items.length,
|
||||
isScanning: libraryState.isScanning,
|
||||
scanProgress: libraryState.scanProgress,
|
||||
scanCurrentFile: libraryState.scanCurrentFile,
|
||||
scanTotalFiles: libraryState.scanTotalFiles,
|
||||
lastScannedAt: libraryState.lastScannedAt,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Scan Settings Section
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.libraryScanSettings),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: context.l10n.libraryEnableLocalLibrary,
|
||||
subtitle: settings.localLibraryEnabled
|
||||
? context.l10n.libraryEnableLocalLibrarySubtitle
|
||||
: context.l10n.extensionsDisabled,
|
||||
value: settings.localLibraryEnabled,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryEnabled(value),
|
||||
),
|
||||
Opacity(
|
||||
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: context.l10n.libraryFolder,
|
||||
subtitle: settings.localLibraryPath.isEmpty
|
||||
? context.l10n.libraryFolderHint
|
||||
: settings.localLibraryPath,
|
||||
onTap: settings.localLibraryEnabled ? _pickLibraryFolder : null,
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.content_copy_outlined,
|
||||
title: context.l10n.libraryShowDuplicateIndicator,
|
||||
subtitle: settings.localLibraryShowDuplicates
|
||||
? context.l10n.libraryShowDuplicateIndicatorSubtitle
|
||||
: context.l10n.extensionsDisabled,
|
||||
value: settings.localLibraryShowDuplicates,
|
||||
enabled: settings.localLibraryEnabled,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryShowDuplicates(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Scan Actions Section
|
||||
if (settings.localLibraryEnabled) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.libraryActions),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
if (libraryState.isScanning)
|
||||
_ScanProgressTile(
|
||||
progress: libraryState.scanProgress,
|
||||
currentFile: libraryState.scanCurrentFile,
|
||||
totalFiles: libraryState.scanTotalFiles,
|
||||
onCancel: _cancelScan,
|
||||
)
|
||||
else
|
||||
Opacity(
|
||||
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.refresh,
|
||||
title: context.l10n.libraryScan,
|
||||
subtitle: settings.localLibraryPath.isEmpty
|
||||
? context.l10n.libraryScanSelectFolderFirst
|
||||
: context.l10n.libraryScanSubtitle,
|
||||
onTap: settings.localLibraryPath.isNotEmpty ? _startScan : null,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
title: context.l10n.libraryCleanupMissingFiles,
|
||||
subtitle: context.l10n.libraryCleanupMissingFilesSubtitle,
|
||||
onTap: libraryState.items.isNotEmpty ? _cleanupMissingFiles : null,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
|
||||
child: SettingsItem(
|
||||
icon: Icons.delete_outline,
|
||||
title: context.l10n.libraryClear,
|
||||
subtitle: context.l10n.libraryClearSubtitle,
|
||||
onTap: libraryState.items.isNotEmpty ? _clearLibrary : null,
|
||||
showDivider: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Info Section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.libraryAbout,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.libraryAboutDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryStatusCard extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final bool isScanning;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
final DateTime? lastScannedAt;
|
||||
|
||||
const _LibraryStatusCard({
|
||||
required this.itemCount,
|
||||
required this.isScanning,
|
||||
required this.scanProgress,
|
||||
this.scanCurrentFile,
|
||||
required this.scanTotalFiles,
|
||||
this.lastScannedAt,
|
||||
});
|
||||
|
||||
String _formatLastScanned(BuildContext context) {
|
||||
if (lastScannedAt == null) return context.l10n.libraryLastScannedNever;
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastScannedAt!);
|
||||
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes} minutes ago';
|
||||
if (diff.inDays < 1) return '${diff.inHours} hours ago';
|
||||
if (diff.inDays < 7) return context.l10n.dateDaysAgo(diff.inDays);
|
||||
|
||||
return '${lastScannedAt!.day}/${lastScannedAt!.month}/${lastScannedAt!.year}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.library_music,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.libraryTracksCount(itemCount),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.libraryLastScanned(_formatLastScanned(context)),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isScanning)
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: scanProgress / 100,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isScanning && scanCurrentFile != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: scanProgress / 100,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
scanCurrentFile!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanProgressTile extends StatelessWidget {
|
||||
final double progress;
|
||||
final String? currentFile;
|
||||
final int totalFiles;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _ScanProgressTile({
|
||||
required this.progress,
|
||||
this.currentFile,
|
||||
required this.totalFiles,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.scanner, color: colorScheme.primary),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.libraryScanning,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.libraryScanProgress(progress.toStringAsFixed(0), totalFiles),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onCancel,
|
||||
child: Text(context.l10n.actionCancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: progress / 100,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
if (currentFile != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
currentFile!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
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/cloud_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
@@ -80,6 +81,12 @@ class SettingsTab extends ConsumerWidget {
|
||||
subtitle: l10n.settingsCloudSaveSubtitle,
|
||||
onTap: () => _navigateTo(context, const CloudSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: l10n.settingsLocalLibrary,
|
||||
subtitle: l10n.settingsLocalLibrarySubtitle,
|
||||
onTap: () => _navigateTo(context, const LibrarySettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: l10n.settingsOptions,
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import 'dart:io';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('LibraryDatabase');
|
||||
|
||||
/// Represents a track in the user's local music library
|
||||
class LocalLibraryItem {
|
||||
final String id;
|
||||
final String trackName;
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String? albumArtist;
|
||||
final String filePath;
|
||||
final DateTime scannedAt;
|
||||
final String? isrc;
|
||||
final int? trackNumber;
|
||||
final int? discNumber;
|
||||
final int? duration;
|
||||
final String? releaseDate;
|
||||
final int? bitDepth;
|
||||
final int? sampleRate;
|
||||
final String? genre;
|
||||
final String? format; // flac, mp3, opus, m4a
|
||||
|
||||
const LocalLibraryItem({
|
||||
required this.id,
|
||||
required this.trackName,
|
||||
required this.artistName,
|
||||
required this.albumName,
|
||||
this.albumArtist,
|
||||
required this.filePath,
|
||||
required this.scannedAt,
|
||||
this.isrc,
|
||||
this.trackNumber,
|
||||
this.discNumber,
|
||||
this.duration,
|
||||
this.releaseDate,
|
||||
this.bitDepth,
|
||||
this.sampleRate,
|
||||
this.genre,
|
||||
this.format,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'trackName': trackName,
|
||||
'artistName': artistName,
|
||||
'albumName': albumName,
|
||||
'albumArtist': albumArtist,
|
||||
'filePath': filePath,
|
||||
'scannedAt': scannedAt.toIso8601String(),
|
||||
'isrc': isrc,
|
||||
'trackNumber': trackNumber,
|
||||
'discNumber': discNumber,
|
||||
'duration': duration,
|
||||
'releaseDate': releaseDate,
|
||||
'bitDepth': bitDepth,
|
||||
'sampleRate': sampleRate,
|
||||
'genre': genre,
|
||||
'format': format,
|
||||
};
|
||||
|
||||
factory LocalLibraryItem.fromJson(Map<String, dynamic> json) =>
|
||||
LocalLibraryItem(
|
||||
id: json['id'] as String,
|
||||
trackName: json['trackName'] as String,
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
filePath: json['filePath'] as String,
|
||||
scannedAt: DateTime.parse(json['scannedAt'] as String),
|
||||
isrc: json['isrc'] as String?,
|
||||
trackNumber: json['trackNumber'] as int?,
|
||||
discNumber: json['discNumber'] as int?,
|
||||
duration: json['duration'] as int?,
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
bitDepth: json['bitDepth'] as int?,
|
||||
sampleRate: json['sampleRate'] as int?,
|
||||
genre: json['genre'] as String?,
|
||||
format: json['format'] as String?,
|
||||
);
|
||||
|
||||
/// Create a unique key for matching tracks
|
||||
String get matchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
String get albumKey => '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}';
|
||||
}
|
||||
|
||||
/// SQLite database service for local library
|
||||
class LibraryDatabase {
|
||||
static final LibraryDatabase instance = LibraryDatabase._init();
|
||||
static Database? _database;
|
||||
|
||||
LibraryDatabase._init();
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDB('local_library.db');
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _initDB(String fileName) async {
|
||||
final dbPath = await getApplicationDocumentsDirectory();
|
||||
final path = join(dbPath.path, fileName);
|
||||
|
||||
_log.i('Initializing library database at: $path');
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createDB(Database db, int version) async {
|
||||
_log.i('Creating library database schema v$version');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE library (
|
||||
id TEXT PRIMARY KEY,
|
||||
track_name TEXT NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL,
|
||||
album_artist TEXT,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
scanned_at TEXT NOT NULL,
|
||||
isrc TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
duration INTEGER,
|
||||
release_date TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
genre TEXT,
|
||||
format TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
// Indexes for fast lookups
|
||||
await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)');
|
||||
await db.execute('CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)');
|
||||
await db.execute('CREATE INDEX idx_library_album ON library(album_name, album_artist)');
|
||||
await db.execute('CREATE INDEX idx_library_file_path ON library(file_path)');
|
||||
|
||||
_log.i('Library database schema created with indexes');
|
||||
}
|
||||
|
||||
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
|
||||
_log.i('Upgrading library database from v$oldVersion to v$newVersion');
|
||||
// Future migrations go here
|
||||
}
|
||||
|
||||
/// Convert JSON format (camelCase) to DB row (snake_case)
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
return {
|
||||
'id': json['id'],
|
||||
'track_name': json['trackName'],
|
||||
'artist_name': json['artistName'],
|
||||
'album_name': json['albumName'],
|
||||
'album_artist': json['albumArtist'],
|
||||
'file_path': json['filePath'],
|
||||
'scanned_at': json['scannedAt'],
|
||||
'isrc': json['isrc'],
|
||||
'track_number': json['trackNumber'],
|
||||
'disc_number': json['discNumber'],
|
||||
'duration': json['duration'],
|
||||
'release_date': json['releaseDate'],
|
||||
'bit_depth': json['bitDepth'],
|
||||
'sample_rate': json['sampleRate'],
|
||||
'genre': json['genre'],
|
||||
'format': json['format'],
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert DB row (snake_case) to JSON format (camelCase)
|
||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||
return {
|
||||
'id': row['id'],
|
||||
'trackName': row['track_name'],
|
||||
'artistName': row['artist_name'],
|
||||
'albumName': row['album_name'],
|
||||
'albumArtist': row['album_artist'],
|
||||
'filePath': row['file_path'],
|
||||
'scannedAt': row['scanned_at'],
|
||||
'isrc': row['isrc'],
|
||||
'trackNumber': row['track_number'],
|
||||
'discNumber': row['disc_number'],
|
||||
'duration': row['duration'],
|
||||
'releaseDate': row['release_date'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'genre': row['genre'],
|
||||
'format': row['format'],
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== CRUD Operations ====================
|
||||
|
||||
/// Insert or update a library item
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
'library',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Batch insert multiple items
|
||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'library',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
_log.i('Batch inserted ${items.length} items');
|
||||
}
|
||||
|
||||
/// Get all library items ordered by album/artist
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
orderBy: 'album_artist, album_name, disc_number, track_number',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Get item by ID
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Get item by ISRC - O(1) with index
|
||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
where: 'isrc = ?',
|
||||
whereArgs: [isrc],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Check if ISRC exists - O(1) with index
|
||||
Future<bool> existsByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery(
|
||||
'SELECT 1 FROM library WHERE isrc = ? LIMIT 1',
|
||||
[isrc],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Find by track name and artist (fuzzy match)
|
||||
Future<List<Map<String, dynamic>>> findByTrackAndArtist(
|
||||
String trackName,
|
||||
String artistName,
|
||||
) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
where: 'LOWER(track_name) = ? AND LOWER(artist_name) = ?',
|
||||
whereArgs: [trackName.toLowerCase(), artistName.toLowerCase()],
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Check if track exists by name and artist
|
||||
Future<Map<String, dynamic>?> findExisting({
|
||||
String? isrc,
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
}) async {
|
||||
// First try ISRC if available
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = await getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
|
||||
// Then try name matching
|
||||
if (trackName != null && artistName != null) {
|
||||
final matches = await findByTrackAndArtist(trackName, artistName);
|
||||
if (matches.isNotEmpty) return matches.first;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all ISRCs as Set for fast in-memory lookup
|
||||
Future<Set<String>> getAllIsrcs() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""'
|
||||
);
|
||||
return rows.map((r) => r['isrc'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Get all track keys (name|artist) for matching
|
||||
Future<Set<String>> getAllTrackKeys() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library'
|
||||
);
|
||||
return rows.map((r) => r['match_key'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Delete by file path
|
||||
Future<void> deleteByPath(String filePath) async {
|
||||
final db = await database;
|
||||
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
|
||||
}
|
||||
|
||||
/// Delete items where file no longer exists
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final db = await database;
|
||||
final rows = await db.query('library', columns: ['id', 'file_path']);
|
||||
|
||||
int removed = 0;
|
||||
for (final row in rows) {
|
||||
final filePath = row['file_path'] as String;
|
||||
if (!await File(filePath).exists()) {
|
||||
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
_log.i('Cleaned up $removed missing files from library');
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// Clear all library data
|
||||
Future<void> clearAll() async {
|
||||
final db = await database;
|
||||
await db.delete('library');
|
||||
_log.i('Cleared all library data');
|
||||
}
|
||||
|
||||
/// Get total count
|
||||
Future<int> getCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM library');
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
/// Search library by query
|
||||
Future<List<Map<String, dynamic>>> search(String query, {int limit = 50}) async {
|
||||
final db = await database;
|
||||
final searchQuery = '%${query.toLowerCase()}%';
|
||||
final rows = await db.query(
|
||||
'library',
|
||||
where: 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?',
|
||||
whereArgs: [searchQuery, searchQuery, searchQuery],
|
||||
orderBy: 'track_name',
|
||||
limit: limit,
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Close database
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
@@ -821,6 +821,44 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== LOCAL LIBRARY SCANNING ====================
|
||||
|
||||
/// Scan a folder for audio files and read their metadata
|
||||
/// Returns a list of track metadata
|
||||
static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async {
|
||||
_log.i('scanLibraryFolder: $folderPath');
|
||||
final result = await _channel.invokeMethod('scanLibraryFolder', {
|
||||
'folder_path': folderPath,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get current library scan progress
|
||||
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
|
||||
final result = await _channel.invokeMethod('getLibraryScanProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Cancel ongoing library scan
|
||||
static Future<void> cancelLibraryScan() async {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
}
|
||||
|
||||
/// Read metadata from a single audio file
|
||||
static Future<Map<String, dynamic>?> readAudioMetadata(String filePath) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('readAudioMetadata', {
|
||||
'file_path': filePath,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.w('Failed to read audio metadata: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static Future<Map<String, dynamic>> runPostProcessing(
|
||||
String filePath, {
|
||||
|
||||
Reference in New Issue
Block a user