diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ce7013..00b5a5ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --- diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 88e7ce4a..16dc6fb6 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -892,6 +892,33 @@ class MainActivity: FlutterActivity() { } result.success(response) } + // Local Library Scanning + "scanLibraryFolder" -> { + val folderPath = call.argument("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("file_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.readAudioMetadataJSON(filePath) + } + result.success(response) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 6d39d8ea..f01352f0 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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) +} diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go new file mode 100644 index 00000000..ea44a698 --- /dev/null +++ b/go_backend/library_scan.go @@ -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 +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 402327fb..36976e10 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e28782fd..daed1f62 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c86c18f5..f0a6ca05 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bff34348..77cc2d41 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 0796eb72..3e81d034 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5471edbd..d14f27be 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 5adf0eb8..94709892 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 705a912b..17d2a083 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 21f11f1b..7cc35619 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index fc7d42b0..9654897e 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 78b5d332..e4007f34 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 86ff1c13..fd1c4262 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3201f305..06bc7a44 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 85a285a5..baf748ac 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a80ecdbe..424089ca 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index f5606cf9..853c14a8 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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"} } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 113e9601..369dea02 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -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, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 40d0d28b..002152ef 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -48,6 +48,10 @@ AppSettings _$AppSettingsFromJson(Map 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 _$AppSettingsToJson(AppSettings instance) => @@ -90,4 +94,7 @@ Map _$AppSettingsToJson(AppSettings instance) => 'cloudUsername': instance.cloudUsername, 'cloudPassword': instance.cloudPassword, 'cloudRemotePath': instance.cloudRemotePath, + 'localLibraryEnabled': instance.localLibraryEnabled, + 'localLibraryPath': instance.localLibraryPath, + 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, }; diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart new file mode 100644 index 00000000..927a9fbc --- /dev/null +++ b/lib/providers/local_library_provider.dart @@ -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 items; + final bool isScanning; + final double scanProgress; + final String? scanCurrentFile; + final int scanTotalFiles; + final int scanErrorCount; + final DateTime? lastScannedAt; + final Set _isrcSet; + final Set _trackKeySet; + final Map _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? 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 { + 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 _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 reloadFromStorage() async { + _isLoaded = false; + await _loadFromDatabase(); + } + + /// Start scanning a folder for audio files + Future 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 = []; + 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 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 cleanupMissingFiles() async { + final removed = await _db.cleanupMissingFiles(); + if (removed > 0) { + await reloadFromStorage(); + } + return removed; + } + + /// Clear all library data + Future 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> 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 getCount() async { + return await _db.getCount(); + } +} + +final localLibraryProvider = + NotifierProvider( + LocalLibraryNotifier.new, + ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 670d0e8f..10369dc4 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -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 { final Future _prefs = SharedPreferences.getInstance(); + final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); @override AppSettings build() { @@ -25,11 +28,13 @@ class SettingsNotifier extends Notifier { state = AppSettings.fromJson(jsonDecode(json)); await _runMigrations(prefs); - - _applySpotifyCredentials(); - - LogBuffer.loggingEnabled = state.enableLogging; } + + await _loadCloudPassword(prefs); + + _applySpotifyCredentials(); + + LogBuffer.loggingEnabled = state.enableLogging; } Future _runMigrations(SharedPreferences prefs) async { @@ -49,7 +54,38 @@ class SettingsNotifier extends Notifier { Future _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 _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 _storeCloudPassword(String password) async { + if (password.isEmpty) { + await _secureStorage.delete(key: _cloudPasswordKey); + } else { + await _secureStorage.write(key: _cloudPasswordKey, value: password); + } } Future _applySpotifyCredentials() async { @@ -272,8 +308,9 @@ void setUseAllFilesAccess(bool enabled) { _saveSettings(); } - void setCloudPassword(String password) { + Future 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 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(); } } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 0ce72f7a..2f1f2e05 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -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, + ), + ), + ], + ), + ), + ], + ], ), ], ), diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart new file mode 100644 index 00000000..d2591795 --- /dev/null +++ b/lib/screens/settings/library_settings_page.dart @@ -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 createState() => _LibrarySettingsPageState(); +} + +class _LibrarySettingsPageState extends ConsumerState { + int _androidSdkVersion = 0; + bool _hasStoragePermission = false; + + @override + void initState() { + super.initState(); + _initDeviceInfo(); + } + + Future _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 _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( + 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 _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 _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 _cancelScan() async { + await ref.read(localLibraryProvider.notifier).cancelScan(); + } + + Future _clearLibrary() async { + final confirmed = await showDialog( + 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 _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, + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index c0d09414..80ecd55e 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -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, diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart new file mode 100644 index 00000000..c4a2c35a --- /dev/null +++ b/lib/services/library_database.dart @@ -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 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 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 get database async { + if (_database != null) return _database!; + _database = await _initDB('local_library.db'); + return _database!; + } + + Future _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 _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 _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 _jsonToDbRow(Map 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 _dbRowToJson(Map 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 upsert(Map json) async { + final db = await database; + await db.insert( + 'library', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Batch insert multiple items + Future upsertBatch(List> 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>> 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?> 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?> 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 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>> 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?> 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> 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> 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 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 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 clearAll() async { + final db = await database; + await db.delete('library'); + _log.i('Cleared all library data'); + } + + /// Get total count + Future 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>> 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 close() async { + final db = await database; + await db.close(); + _database = null; + } +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 08a233b4..307efd87 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -821,6 +821,44 @@ static Future> downloadWithExtensions({ } } + // ==================== LOCAL LIBRARY SCANNING ==================== + + /// Scan a folder for audio files and read their metadata + /// Returns a list of track metadata + static Future>> 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; + return list.map((e) => e as Map).toList(); + } + + /// Get current library scan progress + static Future> getLibraryScanProgress() async { + final result = await _channel.invokeMethod('getLibraryScanProgress'); + return jsonDecode(result as String) as Map; + } + + /// Cancel ongoing library scan + static Future cancelLibraryScan() async { + await _channel.invokeMethod('cancelLibraryScan'); + } + + /// Read metadata from a single audio file + static Future?> 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; + } catch (e) { + _log.w('Failed to read audio metadata: $e'); + return null; + } + } + static Future> runPostProcessing( String filePath, {