feat: add local library scanning with duplicate detection

- Add Go backend library scanner for FLAC, M4A, MP3, Opus, OGG files
- Read metadata from file tags (ISRC, track name, artist, album, bit depth, sample rate)
- Fallback to filename parsing when tags unavailable
- Add SQLite database for O(1) duplicate lookups
- Show 'In Library' badge on search results for existing tracks
- Match by ISRC (exact) or track name + artist (fuzzy)
- Add Library Settings page with scan, cleanup, and clear actions
- Add 30+ localization strings for library feature
This commit is contained in:
zarzet
2026-02-03 19:24:28 +07:00
parent 3d6a3f8d04
commit 26d464d3c7
29 changed files with 4377 additions and 16 deletions
+17
View File
@@ -4,6 +4,16 @@
### Added
- **Local Library Scanning**: Scan your existing music collection to detect duplicates when downloading
- Recursive folder scanning for audio files (FLAC, M4A, MP3, Opus, OGG)
- Reads metadata from file tags (ISRC, track name, artist, album, bit depth, sample rate)
- Fallback to filename parsing when tags unavailable ("Artist - Title" pattern)
- SQLite database for fast O(1) duplicate lookups
- Progress tracking with cancel option during scan
- Cleanup missing files and clear library actions
- **Duplicate Detection in Search Results**: "In Library" badge shows on tracks that exist in your local library
- Matches by ISRC (exact match) or track name + artist (fuzzy match)
- Toggle indicator visibility in Settings > Local Library
- **Cloud Upload with WebDAV & SFTP**: Automatically upload downloaded files to your NAS or cloud storage
- Full WebDAV support (Synology DSM, Nextcloud, QNAP, ownCloud)
- Full SFTP support (any SSH server with SFTP enabled)
@@ -11,11 +21,18 @@
- Upload queue with progress tracking
- Retry failed uploads and clear completed items
- Recent uploads list in Cloud Save settings
- **SFTP Host Key Security (TOFU)**: Verify host keys on connect and block mismatches
- **Reset SFTP Host Keys**: Clear the saved host key for the current server or all servers
### Dependencies
- Added `webdav_client: ^1.2.2` for WebDAV protocol support
- Added `dartssh2: ^2.13.0` for SFTP protocol support
- Added `flutter_secure_storage: ^9.2.2` for storing cloud passwords securely
### Changed
- Cloud upload passwords are now stored in secure storage instead of SharedPreferences
---
@@ -892,6 +892,33 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
// Local Library Scanning
"scanLibraryFolder" -> {
val folderPath = call.argument<String>("folder_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.scanLibraryFolderJSON(folderPath)
}
result.success(response)
}
"getLibraryScanProgress" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getLibraryScanProgressJSON()
}
result.success(response)
}
"cancelLibraryScan" -> {
withContext(Dispatchers.IO) {
Gobackend.cancelLibraryScanJSON()
}
result.success(null)
}
"readAudioMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.readAudioMetadataJSON(filePath)
}
result.success(response)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
+22
View File
@@ -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)
}
+373
View File
@@ -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
}
+23
View File
@@ -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",
+270
View File
@@ -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
+162
View File
@@ -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';
}
+162
View File
@@ -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';
}
+162
View File
@@ -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`).
+162
View File
@@ -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';
}
+162
View File
@@ -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';
}
+162
View File
@@ -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';
}
+162
View File
@@ -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';
}
+162
View File
@@ -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';
}
+162
View File
@@ -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';
}
+162
View File
@@ -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`).
+162
View File
@@ -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';
}
+162
View File
@@ -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';
}
+162
View File
@@ -2086,6 +2086,52 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get cloudSettingsRecentUploads => 'Recent Uploads';
@override
String get cloudSettingsResetSftpHostKey => 'Reset SFTP Host Key';
@override
String get cloudSettingsResetAllSftpHostKeys => 'Reset All SFTP Host Keys';
@override
String get cloudSettingsResetSftpHostKeyMessage =>
'This will forget the saved host key for this server. The next connection will save a new key.';
@override
String get cloudSettingsResetAllSftpHostKeysMessage =>
'This will forget all saved SFTP host keys. Next connections will save new keys.';
@override
String get cloudSettingsResetConfirm => 'Reset';
@override
String get cloudSettingsResetAllConfirm => 'Reset All';
@override
String get cloudSettingsServerUrlRequired => 'Server URL is required';
@override
String get cloudSettingsResetSftpHostKeySuccess =>
'SFTP host key reset. Connect again to save a new key.';
@override
String get cloudSettingsResetSftpHostKeyNotFound =>
'No stored host key found for this server.';
@override
String cloudSettingsResetAllSftpHostKeysCleared(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Cleared $count SFTP host keys.',
one: 'Cleared 1 SFTP host key.',
);
return '$_temp0';
}
@override
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2308,6 +2354,122 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get libraryTitle => 'Local Library';
@override
String get libraryStatus => 'Library Status';
@override
String get libraryScanSettings => 'Scan Settings';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
@override
String get libraryFolderHint => 'Tap to select folder';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
@override
String get libraryScan => 'Scan Library';
@override
String get libraryScanSubtitle => 'Scan for audio files';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@override
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
@override
String get libraryCleanupMissingFilesSubtitle =>
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksCount(int count) {
return '$count tracks';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
}
@override
String get libraryInLibrary => 'In Library';
@override
String libraryRemovedMissingFiles(int count) {
return 'Removed $count missing files from library';
}
@override
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
@override
String get libraryStorageAccessMessage =>
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
}
/// The translations for Chinese, as used in China (`zh_CN`).
+113 -1
View File
@@ -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"}
}
+18 -1
View File
@@ -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,
);
}
+7
View File
@@ -48,6 +48,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
cloudUsername: json['cloudUsername'] as String? ?? '',
cloudPassword: json['cloudPassword'] as String? ?? '',
cloudRemotePath: json['cloudRemotePath'] as String? ?? '/Music/SpotiFLAC',
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -90,4 +94,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'cloudUsername': instance.cloudUsername,
'cloudPassword': instance.cloudPassword,
'cloudRemotePath': instance.cloudRemotePath,
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
};
+275
View File
@@ -0,0 +1,275 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary');
/// State for local library
class LocalLibraryState {
final List<LocalLibraryItem> items;
final bool isScanning;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
final int scanErrorCount;
final DateTime? lastScannedAt;
final Set<String> _isrcSet;
final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc;
LocalLibraryState({
this.items = const [],
this.isScanning = false,
this.scanProgress = 0,
this.scanCurrentFile,
this.scanTotalFiles = 0,
this.scanErrorCount = 0,
this.lastScannedAt,
}) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!)
.toSet(),
_trackKeySet = items.map((item) => item.matchKey).toSet(),
_byIsrc = Map.fromEntries(
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
);
/// Check if ISRC exists in library
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
/// Check if track exists by name and artist
bool hasTrack(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
return _trackKeySet.contains(key);
}
/// Find library item by ISRC
LocalLibraryItem? getByIsrc(String isrc) => _byIsrc[isrc];
/// Find library item by track name and artist
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
return items.where((item) => item.matchKey == key).firstOrNull;
}
/// Check if a track exists in library (by ISRC or name matching)
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) {
return true;
}
if (trackName != null && artistName != null) {
return hasTrack(trackName, artistName);
}
return false;
}
LocalLibraryState copyWith({
List<LocalLibraryItem>? items,
bool? isScanning,
double? scanProgress,
String? scanCurrentFile,
int? scanTotalFiles,
int? scanErrorCount,
DateTime? lastScannedAt,
}) {
return LocalLibraryState(
items: items ?? this.items,
isScanning: isScanning ?? this.isScanning,
scanProgress: scanProgress ?? this.scanProgress,
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
);
}
}
/// Provider for local library state management
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
Timer? _progressTimer;
bool _isLoaded = false;
@override
LocalLibraryState build() {
ref.onDispose(() {
_progressTimer?.cancel();
});
Future.microtask(() async {
await _loadFromDatabase();
});
return LocalLibraryState();
}
Future<void> _loadFromDatabase() async {
if (_isLoaded) return;
_isLoaded = true;
try {
final jsonList = await _db.getAll();
final items = jsonList
.map((e) => LocalLibraryItem.fromJson(e))
.toList();
state = state.copyWith(items: items);
_log.i('Loaded ${items.length} items from library database');
} catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack);
}
}
/// Reload library from database
Future<void> reloadFromStorage() async {
_isLoaded = false;
await _loadFromDatabase();
}
/// Start scanning a folder for audio files
Future<void> startScan(String folderPath) async {
if (state.isScanning) {
_log.w('Scan already in progress');
return;
}
_log.i('Starting library scan: $folderPath');
state = state.copyWith(
isScanning: true,
scanProgress: 0,
scanCurrentFile: null,
scanTotalFiles: 0,
scanErrorCount: 0,
);
// Start progress polling
_startProgressPolling();
try {
final results = await PlatformBridge.scanLibraryFolder(folderPath);
// Convert results to LocalLibraryItem and save to database
final items = <LocalLibraryItem>[];
for (final json in results) {
final item = LocalLibraryItem.fromJson(json);
items.add(item);
}
// Batch insert into database
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
// Update state
state = state.copyWith(
items: items,
isScanning: false,
scanProgress: 100,
lastScannedAt: DateTime.now(),
);
_log.i('Scan complete: ${items.length} tracks found');
} catch (e, stack) {
_log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false);
} finally {
_stopProgressPolling();
}
}
void _startProgressPolling() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
try {
final progress = await PlatformBridge.getLibraryScanProgress();
state = state.copyWith(
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
scanCurrentFile: progress['current_file'] as String?,
scanTotalFiles: progress['total_files'] as int? ?? 0,
scanErrorCount: progress['error_count'] as int? ?? 0,
);
if (progress['is_complete'] == true) {
_stopProgressPolling();
}
} catch (_) {}
});
}
void _stopProgressPolling() {
_progressTimer?.cancel();
_progressTimer = null;
}
/// Cancel ongoing scan
Future<void> cancelScan() async {
if (!state.isScanning) return;
_log.i('Cancelling library scan');
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false);
_stopProgressPolling();
}
/// Clean up missing files from library
Future<int> cleanupMissingFiles() async {
final removed = await _db.cleanupMissingFiles();
if (removed > 0) {
await reloadFromStorage();
}
return removed;
}
/// Clear all library data
Future<void> clearLibrary() async {
await _db.clearAll();
state = LocalLibraryState();
_log.i('Library cleared');
}
/// Check if a track exists in library
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
return state.existsInLibrary(
isrc: isrc,
trackName: trackName,
artistName: artistName,
);
}
/// Get library item by ISRC
LocalLibraryItem? getByIsrc(String isrc) {
return state.getByIsrc(isrc);
}
/// Find library item for a track
LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) {
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
if (trackName != null && artistName != null) {
return state.findByTrackAndArtist(trackName, artistName);
}
return null;
}
/// Search library
Future<List<LocalLibraryItem>> search(String query) async {
if (query.isEmpty) return [];
final results = await _db.search(query);
return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
}
/// Get library count
Future<int> getCount() async {
return await _db.getCount();
}
}
final localLibraryProvider =
NotifierProvider<LocalLibraryNotifier, LocalLibraryState>(
LocalLibraryNotifier.new,
);
+66 -9
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -8,9 +9,11 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
const _cloudPasswordKey = 'cloud_password';
class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@override
AppSettings build() {
@@ -25,11 +28,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs);
_applySpotifyCredentials();
LogBuffer.loggingEnabled = state.enableLogging;
}
await _loadCloudPassword(prefs);
_applySpotifyCredentials();
LogBuffer.loggingEnabled = state.enableLogging;
}
Future<void> _runMigrations(SharedPreferences prefs) async {
@@ -49,7 +54,38 @@ class SettingsNotifier extends Notifier<AppSettings> {
Future<void> _saveSettings() async {
final prefs = await _prefs;
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
final settingsToSave = state.copyWith(cloudPassword: '');
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson()));
}
Future<void> _loadCloudPassword(SharedPreferences prefs) async {
final storedPassword = await _secureStorage.read(key: _cloudPasswordKey);
final prefsPassword = state.cloudPassword;
if ((storedPassword == null || storedPassword.isEmpty) &&
prefsPassword.isNotEmpty) {
await _secureStorage.write(key: _cloudPasswordKey, value: prefsPassword);
}
final effectivePassword = (storedPassword != null && storedPassword.isNotEmpty)
? storedPassword
: (prefsPassword.isNotEmpty ? prefsPassword : '');
if (effectivePassword != state.cloudPassword) {
state = state.copyWith(cloudPassword: effectivePassword);
}
if (prefsPassword.isNotEmpty) {
await _saveSettings();
}
}
Future<void> _storeCloudPassword(String password) async {
if (password.isEmpty) {
await _secureStorage.delete(key: _cloudPasswordKey);
} else {
await _secureStorage.write(key: _cloudPasswordKey, value: password);
}
}
Future<void> _applySpotifyCredentials() async {
@@ -272,8 +308,9 @@ void setUseAllFilesAccess(bool enabled) {
_saveSettings();
}
void setCloudPassword(String password) {
Future<void> setCloudPassword(String password) async {
state = state.copyWith(cloudPassword: password);
await _storeCloudPassword(password);
_saveSettings();
}
@@ -282,22 +319,42 @@ void setUseAllFilesAccess(bool enabled) {
_saveSettings();
}
void setCloudSettings({
Future<void> setCloudSettings({
bool? enabled,
String? provider,
String? serverUrl,
String? username,
String? password,
String? remotePath,
}) {
}) async {
final nextPassword = password ?? state.cloudPassword;
state = state.copyWith(
cloudUploadEnabled: enabled ?? state.cloudUploadEnabled,
cloudProvider: provider ?? state.cloudProvider,
cloudServerUrl: serverUrl ?? state.cloudServerUrl,
cloudUsername: username ?? state.cloudUsername,
cloudPassword: password ?? state.cloudPassword,
cloudPassword: nextPassword,
cloudRemotePath: remotePath ?? state.cloudRemotePath,
);
if (password != null) {
await _storeCloudPassword(nextPassword);
}
_saveSettings();
}
// Local Library Settings
void setLocalLibraryEnabled(bool enabled) {
state = state.copyWith(localLibraryEnabled: enabled);
_saveSettings();
}
void setLocalLibraryPath(String path) {
state = state.copyWith(localLibraryPath: path);
_saveSettings();
}
void setLocalLibraryShowDuplicates(bool show) {
state = state.copyWith(localLibraryShowDuplicates: show);
_saveSettings();
}
}
+53 -5
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
@@ -2417,6 +2418,18 @@ class _TrackItemWithStatus extends ConsumerWidget {
return state.isDownloaded(track.id);
}));
// Check local library for duplicate detection
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(localLibraryProvider.select((state) =>
state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
)))
: false;
double thumbWidth = 56;
double thumbHeight = 56;
@@ -2490,11 +2503,46 @@ class _TrackItemWithStatus extends ConsumerWidget {
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
track.artistName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
Row(
children: [
Flexible(
child: Text(
track.artistName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isInLocalLibrary) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3),
Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
],
),
),
],
],
),
],
),
@@ -0,0 +1,572 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget {
const LibrarySettingsPage({super.key});
@override
ConsumerState<LibrarySettingsPage> createState() => _LibrarySettingsPageState();
}
class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
int _androidSdkVersion = 0;
bool _hasStoragePermission = false;
@override
void initState() {
super.initState();
_initDeviceInfo();
}
Future<void> _initDeviceInfo() async {
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
final sdkVersion = androidInfo.version.sdkInt;
// Check appropriate storage permission based on Android version
bool hasPermission;
if (sdkVersion >= 30) {
hasPermission = await Permission.manageExternalStorage.isGranted;
} else {
hasPermission = await Permission.storage.isGranted;
}
if (mounted) {
setState(() {
_androidSdkVersion = sdkVersion;
_hasStoragePermission = hasPermission;
});
}
} else if (Platform.isIOS) {
// iOS doesn't need explicit storage permission for app documents
setState(() => _hasStoragePermission = true);
}
}
Future<bool> _requestStoragePermission() async {
if (Platform.isIOS) return true;
PermissionStatus status;
if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.request();
} else {
status = await Permission.storage.request();
}
if (status.isGranted) {
setState(() => _hasStoragePermission = true);
return true;
} else if (status.isPermanentlyDenied) {
if (mounted) {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.libraryStorageAccessRequired),
content: Text(context.l10n.libraryStorageAccessMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.setupOpenSettings),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
}
return false;
}
Future<void> _pickLibraryFolder() async {
// Request permission first
if (!_hasStoragePermission) {
final granted = await _requestStoragePermission();
if (!granted) return;
}
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
}
Future<void> _startScan() async {
final settings = ref.read(settingsProvider);
final libraryPath = settings.localLibraryPath;
if (libraryPath.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.libraryScanSelectFolderFirst)),
);
return;
}
// Check if folder exists
if (!await Directory(libraryPath).exists()) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.libraryFolderNotExist)),
);
}
return;
}
await ref.read(localLibraryProvider.notifier).startScan(libraryPath);
}
Future<void> _cancelScan() async {
await ref.read(localLibraryProvider.notifier).cancelScan();
}
Future<void> _clearLibrary() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.libraryClearConfirmTitle),
content: Text(context.l10n.libraryClearConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogClear),
),
],
),
);
if (confirmed == true) {
await ref.read(localLibraryProvider.notifier).clearLibrary();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.libraryCleared)),
);
}
}
}
Future<void> _cleanupMissingFiles() async {
final removed = await ref.read(localLibraryProvider.notifier).cleanupMissingFiles();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.libraryRemovedMissingFiles(removed))),
);
}
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final libraryState = ref.watch(localLibraryProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.libraryTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
// Library Status Section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryStatus),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_LibraryStatusCard(
itemCount: libraryState.items.length,
isScanning: libraryState.isScanning,
scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile,
scanTotalFiles: libraryState.scanTotalFiles,
lastScannedAt: libraryState.lastScannedAt,
),
],
),
),
// Scan Settings Section
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryScanSettings),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.library_music_outlined,
title: context.l10n.libraryEnableLocalLibrary,
subtitle: settings.localLibraryEnabled
? context.l10n.libraryEnableLocalLibrarySubtitle
: context.l10n.extensionsDisabled,
value: settings.localLibraryEnabled,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setLocalLibraryEnabled(value),
),
Opacity(
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.folder_outlined,
title: context.l10n.libraryFolder,
subtitle: settings.localLibraryPath.isEmpty
? context.l10n.libraryFolderHint
: settings.localLibraryPath,
onTap: settings.localLibraryEnabled ? _pickLibraryFolder : null,
),
),
SettingsSwitchItem(
icon: Icons.content_copy_outlined,
title: context.l10n.libraryShowDuplicateIndicator,
subtitle: settings.localLibraryShowDuplicates
? context.l10n.libraryShowDuplicateIndicatorSubtitle
: context.l10n.extensionsDisabled,
value: settings.localLibraryShowDuplicates,
enabled: settings.localLibraryEnabled,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setLocalLibraryShowDuplicates(value),
showDivider: false,
),
],
),
),
// Scan Actions Section
if (settings.localLibraryEnabled) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryActions),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
if (libraryState.isScanning)
_ScanProgressTile(
progress: libraryState.scanProgress,
currentFile: libraryState.scanCurrentFile,
totalFiles: libraryState.scanTotalFiles,
onCancel: _cancelScan,
)
else
Opacity(
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.refresh,
title: context.l10n.libraryScan,
subtitle: settings.localLibraryPath.isEmpty
? context.l10n.libraryScanSelectFolderFirst
: context.l10n.libraryScanSubtitle,
onTap: settings.localLibraryPath.isNotEmpty ? _startScan : null,
),
),
Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.libraryCleanupMissingFiles,
subtitle: context.l10n.libraryCleanupMissingFilesSubtitle,
onTap: libraryState.items.isNotEmpty ? _cleanupMissingFiles : null,
),
),
Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.delete_outline,
title: context.l10n.libraryClear,
subtitle: context.l10n.libraryClearSubtitle,
onTap: libraryState.items.isNotEmpty ? _clearLibrary : null,
showDivider: false,
),
),
],
),
),
],
// Info Section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.libraryAbout,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
context.l10n.libraryAboutDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
],
),
),
],
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
}
class _LibraryStatusCard extends StatelessWidget {
final int itemCount;
final bool isScanning;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
final DateTime? lastScannedAt;
const _LibraryStatusCard({
required this.itemCount,
required this.isScanning,
required this.scanProgress,
this.scanCurrentFile,
required this.scanTotalFiles,
this.lastScannedAt,
});
String _formatLastScanned(BuildContext context) {
if (lastScannedAt == null) return context.l10n.libraryLastScannedNever;
final now = DateTime.now();
final diff = now.difference(lastScannedAt!);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inHours < 1) return '${diff.inMinutes} minutes ago';
if (diff.inDays < 1) return '${diff.inHours} hours ago';
if (diff.inDays < 7) return context.l10n.dateDaysAgo(diff.inDays);
return '${lastScannedAt!.day}/${lastScannedAt!.month}/${lastScannedAt!.year}';
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.library_music,
color: colorScheme.onPrimaryContainer,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.libraryTracksCount(itemCount),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
context.l10n.libraryLastScanned(_formatLastScanned(context)),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isScanning)
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
value: scanProgress / 100,
color: colorScheme.primary,
),
),
],
),
if (isScanning && scanCurrentFile != null) ...[
const SizedBox(height: 12),
LinearProgressIndicator(
value: scanProgress / 100,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 8),
Text(
scanCurrentFile!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
}
class _ScanProgressTile extends StatelessWidget {
final double progress;
final String? currentFile;
final int totalFiles;
final VoidCallback onCancel;
const _ScanProgressTile({
required this.progress,
this.currentFile,
required this.totalFiles,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.scanner, color: colorScheme.primary),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.libraryScanning,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
context.l10n.libraryScanProgress(progress.toStringAsFixed(0), totalFiles),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
TextButton(
onPressed: onCancel,
child: Text(context.l10n.actionCancel),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress / 100,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
if (currentFile != null) ...[
const SizedBox(height: 4),
Text(
currentFile!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
}
+7
View File
@@ -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,
+390
View File
@@ -0,0 +1,390 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LibraryDatabase');
/// Represents a track in the user's local music library
class LocalLibraryItem {
final String id;
final String trackName;
final String artistName;
final String albumName;
final String? albumArtist;
final String filePath;
final DateTime scannedAt;
final String? isrc;
final int? trackNumber;
final int? discNumber;
final int? duration;
final String? releaseDate;
final int? bitDepth;
final int? sampleRate;
final String? genre;
final String? format; // flac, mp3, opus, m4a
const LocalLibraryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.albumArtist,
required this.filePath,
required this.scannedAt,
this.isrc,
this.trackNumber,
this.discNumber,
this.duration,
this.releaseDate,
this.bitDepth,
this.sampleRate,
this.genre,
this.format,
});
Map<String, dynamic> toJson() => {
'id': id,
'trackName': trackName,
'artistName': artistName,
'albumName': albumName,
'albumArtist': albumArtist,
'filePath': filePath,
'scannedAt': scannedAt.toIso8601String(),
'isrc': isrc,
'trackNumber': trackNumber,
'discNumber': discNumber,
'duration': duration,
'releaseDate': releaseDate,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
'genre': genre,
'format': format,
};
factory LocalLibraryItem.fromJson(Map<String, dynamic> json) =>
LocalLibraryItem(
id: json['id'] as String,
trackName: json['trackName'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
filePath: json['filePath'] as String,
scannedAt: DateTime.parse(json['scannedAt'] as String),
isrc: json['isrc'] as String?,
trackNumber: json['trackNumber'] as int?,
discNumber: json['discNumber'] as int?,
duration: json['duration'] as int?,
releaseDate: json['releaseDate'] as String?,
bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?,
genre: json['genre'] as String?,
format: json['format'] as String?,
);
/// Create a unique key for matching tracks
String get matchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
String get albumKey => '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}';
}
/// SQLite database service for local library
class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
static Database? _database;
LibraryDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('local_library.db');
return _database!;
}
Future<Database> _initDB(String fileName) async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName);
_log.i('Initializing library database at: $path');
return await openDatabase(
path,
version: 1,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
}
Future<void> _createDB(Database db, int version) async {
_log.i('Creating library database schema v$version');
await db.execute('''
CREATE TABLE library (
id TEXT PRIMARY KEY,
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
album_artist TEXT,
file_path TEXT NOT NULL UNIQUE,
scanned_at TEXT NOT NULL,
isrc TEXT,
track_number INTEGER,
disc_number INTEGER,
duration INTEGER,
release_date TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
genre TEXT,
format TEXT
)
''');
// Indexes for fast lookups
await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)');
await db.execute('CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)');
await db.execute('CREATE INDEX idx_library_album ON library(album_name, album_artist)');
await db.execute('CREATE INDEX idx_library_file_path ON library(file_path)');
_log.i('Library database schema created with indexes');
}
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading library database from v$oldVersion to v$newVersion');
// Future migrations go here
}
/// Convert JSON format (camelCase) to DB row (snake_case)
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
'id': json['id'],
'track_name': json['trackName'],
'artist_name': json['artistName'],
'album_name': json['albumName'],
'album_artist': json['albumArtist'],
'file_path': json['filePath'],
'scanned_at': json['scannedAt'],
'isrc': json['isrc'],
'track_number': json['trackNumber'],
'disc_number': json['discNumber'],
'duration': json['duration'],
'release_date': json['releaseDate'],
'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'],
'genre': json['genre'],
'format': json['format'],
};
}
/// Convert DB row (snake_case) to JSON format (camelCase)
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return {
'id': row['id'],
'trackName': row['track_name'],
'artistName': row['artist_name'],
'albumName': row['album_name'],
'albumArtist': row['album_artist'],
'filePath': row['file_path'],
'scannedAt': row['scanned_at'],
'isrc': row['isrc'],
'trackNumber': row['track_number'],
'discNumber': row['disc_number'],
'duration': row['duration'],
'releaseDate': row['release_date'],
'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'],
'genre': row['genre'],
'format': row['format'],
};
}
// ==================== CRUD Operations ====================
/// Insert or update a library item
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
'library',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Batch insert multiple items
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
final db = await database;
final batch = db.batch();
for (final json in items) {
batch.insert(
'library',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
_log.i('Batch inserted ${items.length} items');
}
/// Get all library items ordered by album/artist
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
final rows = await db.query(
'library',
orderBy: 'album_artist, album_name, disc_number, track_number',
limit: limit,
offset: offset,
);
return rows.map(_dbRowToJson).toList();
}
/// Get item by ID
Future<Map<String, dynamic>?> getById(String id) async {
final db = await database;
final rows = await db.query(
'library',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Get item by ISRC - O(1) with index
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database;
final rows = await db.query(
'library',
where: 'isrc = ?',
whereArgs: [isrc],
limit: 1,
);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Check if ISRC exists - O(1) with index
Future<bool> existsByIsrc(String isrc) async {
final db = await database;
final result = await db.rawQuery(
'SELECT 1 FROM library WHERE isrc = ? LIMIT 1',
[isrc],
);
return result.isNotEmpty;
}
/// Find by track name and artist (fuzzy match)
Future<List<Map<String, dynamic>>> findByTrackAndArtist(
String trackName,
String artistName,
) async {
final db = await database;
final rows = await db.query(
'library',
where: 'LOWER(track_name) = ? AND LOWER(artist_name) = ?',
whereArgs: [trackName.toLowerCase(), artistName.toLowerCase()],
);
return rows.map(_dbRowToJson).toList();
}
/// Check if track exists by name and artist
Future<Map<String, dynamic>?> findExisting({
String? isrc,
String? trackName,
String? artistName,
}) async {
// First try ISRC if available
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = await getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
// Then try name matching
if (trackName != null && artistName != null) {
final matches = await findByTrackAndArtist(trackName, artistName);
if (matches.isNotEmpty) return matches.first;
}
return null;
}
/// Get all ISRCs as Set for fast in-memory lookup
Future<Set<String>> getAllIsrcs() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""'
);
return rows.map((r) => r['isrc'] as String).toSet();
}
/// Get all track keys (name|artist) for matching
Future<Set<String>> getAllTrackKeys() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT LOWER(track_name) || "|" || LOWER(artist_name) as match_key FROM library'
);
return rows.map((r) => r['match_key'] as String).toSet();
}
/// Delete by file path
Future<void> deleteByPath(String filePath) async {
final db = await database;
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
}
/// Delete items where file no longer exists
Future<int> cleanupMissingFiles() async {
final db = await database;
final rows = await db.query('library', columns: ['id', 'file_path']);
int removed = 0;
for (final row in rows) {
final filePath = row['file_path'] as String;
if (!await File(filePath).exists()) {
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
removed++;
}
}
if (removed > 0) {
_log.i('Cleaned up $removed missing files from library');
}
return removed;
}
/// Clear all library data
Future<void> clearAll() async {
final db = await database;
await db.delete('library');
_log.i('Cleared all library data');
}
/// Get total count
Future<int> getCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM library');
return Sqflite.firstIntValue(result) ?? 0;
}
/// Search library by query
Future<List<Map<String, dynamic>>> search(String query, {int limit = 50}) async {
final db = await database;
final searchQuery = '%${query.toLowerCase()}%';
final rows = await db.query(
'library',
where: 'LOWER(track_name) LIKE ? OR LOWER(artist_name) LIKE ? OR LOWER(album_name) LIKE ?',
whereArgs: [searchQuery, searchQuery, searchQuery],
orderBy: 'track_name',
limit: limit,
);
return rows.map(_dbRowToJson).toList();
}
/// Close database
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
}
+38
View File
@@ -821,6 +821,44 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
}
}
// ==================== LOCAL LIBRARY SCANNING ====================
/// Scan a folder for audio files and read their metadata
/// Returns a list of track metadata
static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async {
_log.i('scanLibraryFolder: $folderPath');
final result = await _channel.invokeMethod('scanLibraryFolder', {
'folder_path': folderPath,
});
final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Get current library scan progress
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
final result = await _channel.invokeMethod('getLibraryScanProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Cancel ongoing library scan
static Future<void> cancelLibraryScan() async {
await _channel.invokeMethod('cancelLibraryScan');
}
/// Read metadata from a single audio file
static Future<Map<String, dynamic>?> readAudioMetadata(String filePath) async {
try {
final result = await _channel.invokeMethod('readAudioMetadata', {
'file_path': filePath,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.w('Failed to read audio metadata: $e');
return null;
}
}
static Future<Map<String, dynamic>> runPostProcessing(
String filePath, {