feat: rename History tab to Library and show local library items

- Rename bottom navigation 'History' to 'Library'
- Add Local Library section showing scanned tracks below downloaded tracks
- Add source badge to each item (Downloaded/Local) for clear identification
- Add new localization strings for Library tab and source badges
- Local library items can be played directly from the library tab
This commit is contained in:
zarzet
2026-02-03 19:53:53 +07:00
parent 22f001a735
commit 9c22f41a3e
29 changed files with 2969 additions and 80 deletions
+9
View File
@@ -14,6 +14,10 @@
- **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
- **Unified Library Tab**: History tab renamed to Library, now shows both Downloaded and Local Library tracks
- Source badge on each item (Downloaded/Local) to identify the source
- Local Library items shown in a separate section when enabled
- Play button to open local library tracks directly
- **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)
@@ -33,6 +37,11 @@
### Changed
- Cloud upload passwords are now stored in secure storage instead of SharedPreferences
- Spotify client secrets are now stored in secure storage instead of SharedPreferences
- Extension HTTP sandbox now enforces HTTPS and blocks private IPs resolved via DNS
- Extension file sandbox now validates paths using boundary-safe checks
- WebDAV now defaults to HTTPS; insecure HTTP requires explicit opt-in
- WebDAV error messages are now localized in the UI
---
+1 -1
View File
@@ -1,2 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+46 -21
View File
@@ -1,8 +1,11 @@
package gobackend
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -104,7 +107,16 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Validate redirect target domain against allowed domains
if req.URL.Scheme != "https" {
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
return fmt.Errorf("redirect blocked: only https is allowed")
}
domain := req.URL.Hostname()
if domain == "" {
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
return fmt.Errorf("redirect blocked: hostname is required")
}
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
@@ -139,35 +151,48 @@ func (e *RedirectBlockedError) Error() string {
// isPrivateIP checks if a hostname resolves to a private/local IP address
func isPrivateIP(host string) bool {
// Block common private network patterns
// This is a simple check - for production, consider DNS resolution
privatePatterns := []string{
"localhost",
"127.",
"10.",
"172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.",
"172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.",
"192.168.",
"169.254.",
"::1",
"fc00:",
"fe80:",
hostLower := strings.ToLower(strings.TrimSpace(host))
if hostLower == "" {
return false
}
hostLower := host
for _, pattern := range privatePatterns {
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
return true
}
if ip := net.ParseIP(hostLower); ip != nil {
return isPrivateIPAddr(ip)
}
ips, err := net.LookupIP(hostLower)
if err != nil {
return false
}
for _, ip := range ips {
if isPrivateIPAddr(ip) {
return true
}
}
// Also block .local domains
if len(host) > 6 && host[len(host)-6:] == ".local" {
return false
}
func isPrivateIPAddr(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified() {
return true
}
if !ip.IsGlobalUnicast() {
return true
}
return false
}
+28 -2
View File
@@ -41,13 +41,39 @@ func isPathInAllowedDirs(absPath string) bool {
defer allowedDownloadDirsMu.RUnlock()
for _, allowedDir := range allowedDownloadDirs {
if strings.HasPrefix(absPath, allowedDir) {
if isPathWithinBase(allowedDir, absPath) {
return true
}
}
return false
}
func isPathWithinBase(baseDir, targetPath string) bool {
baseAbs, err := filepath.Abs(baseDir)
if err != nil {
return false
}
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return false
}
rel, err := filepath.Rel(baseAbs, targetAbs)
if err != nil {
return false
}
rel = filepath.Clean(rel)
if rel == "." {
return true
}
prefix := ".." + string(filepath.Separator)
if rel == ".." || strings.HasPrefix(rel, prefix) {
return false
}
return true
}
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
// Check if extension has file permission
if !r.manifest.Permissions.File {
@@ -77,7 +103,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
}
absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) {
if !isPathWithinBase(absDataDir, absPath) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
}
+3
View File
@@ -34,6 +34,9 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
}
domain := parsed.Hostname()
if domain == "" {
return fmt.Errorf("invalid URL: hostname is required")
}
// Block private/local network access (SSRF protection)
if isPrivateIP(domain) {
+247 -1
View File
@@ -142,7 +142,13 @@ abstract class AppLocalizations {
/// **'Home'**
String get navHome;
/// Bottom navigation - History tab
/// Bottom navigation - Library tab
///
/// In en, this message translates to:
/// **'Library'**
String get navLibrary;
/// Bottom navigation - History tab (legacy)
///
/// In en, this message translates to:
/// **'History'**
@@ -3898,6 +3904,96 @@ abstract class AppLocalizations {
/// **'No stored SFTP host keys found.'**
String get cloudSettingsResetAllSftpHostKeysNone;
/// Toggle/title for allowing insecure HTTP WebDAV connections
///
/// In en, this message translates to:
/// **'Allow HTTP (Insecure)'**
String get cloudSettingsAllowHttpTitle;
/// Subtitle warning for allowing insecure HTTP
///
/// In en, this message translates to:
/// **'Sends credentials without TLS. Not recommended.'**
String get cloudSettingsAllowHttpSubtitle;
/// Dialog warning message for enabling insecure HTTP
///
/// In en, this message translates to:
/// **'HTTP does not encrypt your credentials. Only enable if you trust the network.'**
String get cloudSettingsAllowHttpMessage;
/// Dialog confirm button for enabling insecure HTTP
///
/// In en, this message translates to:
/// **'Allow HTTP'**
String get cloudSettingsAllowHttpConfirm;
/// WebDAV error when URL scheme is missing
///
/// In en, this message translates to:
/// **'Invalid URL: scheme is required'**
String get webdavErrorInvalidScheme;
/// WebDAV error when HTTPS is required
///
/// In en, this message translates to:
/// **'WebDAV URL must use https'**
String get webdavErrorHttpsRequired;
/// WebDAV error when hostname is missing
///
/// In en, this message translates to:
/// **'Invalid URL: hostname is required'**
String get webdavErrorInvalidHost;
/// WebDAV error when authentication fails
///
/// In en, this message translates to:
/// **'Authentication failed. Check username and password.'**
String get webdavErrorAuthFailed;
/// WebDAV error when access is forbidden
///
/// In en, this message translates to:
/// **'Access denied. Check permissions on the server.'**
String get webdavErrorForbidden;
/// WebDAV error when path is not found
///
/// In en, this message translates to:
/// **'Server path not found. Check the URL.'**
String get webdavErrorNotFound;
/// WebDAV error when connection fails
///
/// In en, this message translates to:
/// **'Cannot connect to server. Check URL and network.'**
String get webdavErrorConnectionFailed;
/// WebDAV error for TLS issues
///
/// In en, this message translates to:
/// **'SSL/TLS error. Server certificate may be invalid.'**
String get webdavErrorTlsError;
/// WebDAV error when connection times out
///
/// In en, this message translates to:
/// **'Connection timed out. Server may be unreachable.'**
String get webdavErrorTimeout;
/// WebDAV error when server storage is full
///
/// In en, this message translates to:
/// **'Insufficient storage on server.'**
String get webdavErrorInsufficientStorage;
/// WebDAV fallback error message
///
/// In en, this message translates to:
/// **'Upload failed. Please try again.'**
String get webdavErrorUnknown;
/// Empty queue state title
///
/// In en, this message translates to:
@@ -4455,6 +4551,156 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Selected folder does not exist'**
String get libraryFolderNotExist;
/// Badge for tracks downloaded via SpotiFLAC
///
/// In en, this message translates to:
/// **'Downloaded'**
String get librarySourceDownloaded;
/// Badge for tracks from local library scan
///
/// In en, this message translates to:
/// **'Local'**
String get librarySourceLocal;
/// Filter chip - show all library items
///
/// In en, this message translates to:
/// **'All'**
String get libraryFilterAll;
/// Filter chip - show only downloaded items
///
/// In en, this message translates to:
/// **'Downloaded'**
String get libraryFilterDownloaded;
/// Filter chip - show only local library items
///
/// In en, this message translates to:
/// **'Local'**
String get libraryFilterLocal;
/// Relative time - less than a minute ago
///
/// In en, this message translates to:
/// **'Just now'**
String get timeJustNow;
/// Relative time - minutes ago
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 minute ago} other{{count} minutes ago}}'**
String timeMinutesAgo(int count);
/// Relative time - hours ago
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'**
String timeHoursAgo(int count);
/// WebDAV provider option with examples
///
/// In en, this message translates to:
/// **'WebDAV (Synology, Nextcloud, QNAP)'**
String get cloudProviderWebdav;
/// SFTP provider option
///
/// In en, this message translates to:
/// **'SFTP (SSH File Transfer)'**
String get cloudProviderSftp;
/// No cloud provider selected
///
/// In en, this message translates to:
/// **'Not Configured'**
String get cloudProviderNotConfigured;
/// WebDAV provider title in picker
///
/// In en, this message translates to:
/// **'WebDAV'**
String get cloudProviderWebdavTitle;
/// WebDAV provider subtitle in picker
///
/// In en, this message translates to:
/// **'Synology, Nextcloud, QNAP, ownCloud'**
String get cloudProviderWebdavSubtitle;
/// SFTP provider title in picker
///
/// In en, this message translates to:
/// **'SFTP'**
String get cloudProviderSftpTitle;
/// SFTP provider subtitle in picker
///
/// In en, this message translates to:
/// **'SSH File Transfer Protocol'**
String get cloudProviderSftpSubtitle;
/// Error when testing connection without server URL
///
/// In en, this message translates to:
/// **'Server URL is required'**
String get cloudTestErrorServerUrlRequired;
/// Error when testing connection without credentials
///
/// In en, this message translates to:
/// **'Username and password are required'**
String get cloudTestErrorCredentialsRequired;
/// Success message for WebDAV connection test
///
/// In en, this message translates to:
/// **'Connected to WebDAV server'**
String get cloudTestSuccessWebdav;
/// Success message for SFTP connection test
///
/// In en, this message translates to:
/// **'Connected to SFTP server'**
String get cloudTestSuccessSftp;
/// Error when testing connection without provider
///
/// In en, this message translates to:
/// **'No provider selected'**
String get cloudTestErrorNoProvider;
/// Connection test success message
///
/// In en, this message translates to:
/// **'Success: {message}'**
String connectionTestSuccess(String message);
/// Upload queue status - waiting
///
/// In en, this message translates to:
/// **'Pending'**
String get uploadStatusPending;
/// Upload queue status - in progress
///
/// In en, this message translates to:
/// **'Uploading'**
String get uploadStatusUploading;
/// Upload queue status - completed
///
/// In en, this message translates to:
/// **'Done'**
String get uploadStatusDone;
/// Upload queue status - error
///
/// In en, this message translates to:
/// **'Failed'**
String get uploadStatusFailed;
}
class _AppLocalizationsDelegate
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get navHome => 'Startseite';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'Verlauf';
@@ -2147,6 +2150,59 @@ class AppLocalizationsDe extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2485,4 +2541,99 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -2132,6 +2135,59 @@ class AppLocalizationsEn extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2470,4 +2526,99 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -2132,6 +2135,59 @@ class AppLocalizationsEs extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2470,6 +2526,101 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -2132,6 +2135,59 @@ class AppLocalizationsFr extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2470,4 +2526,99 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get navHome => 'होम';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'इतिहास';
@@ -2132,6 +2135,59 @@ class AppLocalizationsHi extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2470,4 +2526,99 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get navHome => 'Beranda';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'Riwayat';
@@ -2145,6 +2148,59 @@ class AppLocalizationsId extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'Tidak ada unduhan dalam antrian';
@@ -2483,4 +2539,99 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get navHome => 'ホーム';
@override
String get navLibrary => 'Library';
@override
String get navHistory => '履歴';
@@ -2119,6 +2122,59 @@ class AppLocalizationsJa extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'キューにダウンロードがありません';
@@ -2456,4 +2512,99 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -2132,6 +2135,59 @@ class AppLocalizationsKo extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2470,4 +2526,99 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -2132,6 +2135,59 @@ class AppLocalizationsNl extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2470,4 +2526,99 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -2132,6 +2135,59 @@ class AppLocalizationsPt extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2470,6 +2526,101 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get navHome => 'Главная';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'История';
@@ -2171,6 +2174,59 @@ class AppLocalizationsRu extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'Нет загрузок в очереди';
@@ -2516,4 +2572,99 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get navHome => 'Ara';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'Geçmiş';
@@ -2147,6 +2150,59 @@ class AppLocalizationsTr extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2485,4 +2541,99 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
+151
View File
@@ -18,6 +18,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navLibrary => 'Library';
@override
String get navHistory => 'History';
@@ -2132,6 +2135,59 @@ class AppLocalizationsZh extends AppLocalizations {
String get cloudSettingsResetAllSftpHostKeysNone =>
'No stored SFTP host keys found.';
@override
String get cloudSettingsAllowHttpTitle => 'Allow HTTP (Insecure)';
@override
String get cloudSettingsAllowHttpSubtitle =>
'Sends credentials without TLS. Not recommended.';
@override
String get cloudSettingsAllowHttpMessage =>
'HTTP does not encrypt your credentials. Only enable if you trust the network.';
@override
String get cloudSettingsAllowHttpConfirm => 'Allow HTTP';
@override
String get webdavErrorInvalidScheme => 'Invalid URL: scheme is required';
@override
String get webdavErrorHttpsRequired => 'WebDAV URL must use https';
@override
String get webdavErrorInvalidHost => 'Invalid URL: hostname is required';
@override
String get webdavErrorAuthFailed =>
'Authentication failed. Check username and password.';
@override
String get webdavErrorForbidden =>
'Access denied. Check permissions on the server.';
@override
String get webdavErrorNotFound => 'Server path not found. Check the URL.';
@override
String get webdavErrorConnectionFailed =>
'Cannot connect to server. Check URL and network.';
@override
String get webdavErrorTlsError =>
'SSL/TLS error. Server certificate may be invalid.';
@override
String get webdavErrorTimeout =>
'Connection timed out. Server may be unreachable.';
@override
String get webdavErrorInsufficientStorage =>
'Insufficient storage on server.';
@override
String get webdavErrorUnknown => 'Upload failed. Please try again.';
@override
String get queueEmpty => 'No downloads in queue';
@@ -2470,6 +2526,101 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
@override
String get librarySourceLocal => 'Local';
@override
String get libraryFilterAll => 'All';
@override
String get libraryFilterDownloaded => 'Downloaded';
@override
String get libraryFilterLocal => 'Local';
@override
String get timeJustNow => 'Just now';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
);
return '$_temp0';
}
@override
String timeHoursAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
);
return '$_temp0';
}
@override
String get cloudProviderWebdav => 'WebDAV (Synology, Nextcloud, QNAP)';
@override
String get cloudProviderSftp => 'SFTP (SSH File Transfer)';
@override
String get cloudProviderNotConfigured => 'Not Configured';
@override
String get cloudProviderWebdavTitle => 'WebDAV';
@override
String get cloudProviderWebdavSubtitle =>
'Synology, Nextcloud, QNAP, ownCloud';
@override
String get cloudProviderSftpTitle => 'SFTP';
@override
String get cloudProviderSftpSubtitle => 'SSH File Transfer Protocol';
@override
String get cloudTestErrorServerUrlRequired => 'Server URL is required';
@override
String get cloudTestErrorCredentialsRequired =>
'Username and password are required';
@override
String get cloudTestSuccessWebdav => 'Connected to WebDAV server';
@override
String get cloudTestSuccessSftp => 'Connected to SFTP server';
@override
String get cloudTestErrorNoProvider => 'No provider selected';
@override
String connectionTestSuccess(String message) {
return 'Success: $message';
}
@override
String get uploadStatusPending => 'Pending';
@override
String get uploadStatusUploading => 'Uploading';
@override
String get uploadStatusDone => 'Done';
@override
String get uploadStatusFailed => 'Failed';
}
/// The translations for Chinese, as used in China (`zh_CN`).
+104 -2
View File
@@ -9,8 +9,10 @@
"navHome": "Home",
"@navHome": {"description": "Bottom navigation - Home tab"},
"navLibrary": "Library",
"@navLibrary": {"description": "Bottom navigation - Library tab"},
"navHistory": "History",
"@navHistory": {"description": "Bottom navigation - History tab"},
"@navHistory": {"description": "Bottom navigation - History tab (legacy)"},
"navSettings": "Settings",
"@navSettings": {"description": "Bottom navigation - Settings tab"},
"navStore": "Store",
@@ -1549,6 +1551,36 @@
"@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"},
"cloudSettingsAllowHttpTitle": "Allow HTTP (Insecure)",
"@cloudSettingsAllowHttpTitle": {"description": "Toggle/title for allowing insecure HTTP WebDAV connections"},
"cloudSettingsAllowHttpSubtitle": "Sends credentials without TLS. Not recommended.",
"@cloudSettingsAllowHttpSubtitle": {"description": "Subtitle warning for allowing insecure HTTP"},
"cloudSettingsAllowHttpMessage": "HTTP does not encrypt your credentials. Only enable if you trust the network.",
"@cloudSettingsAllowHttpMessage": {"description": "Dialog warning message for enabling insecure HTTP"},
"cloudSettingsAllowHttpConfirm": "Allow HTTP",
"@cloudSettingsAllowHttpConfirm": {"description": "Dialog confirm button for enabling insecure HTTP"},
"webdavErrorInvalidScheme": "Invalid URL: scheme is required",
"@webdavErrorInvalidScheme": {"description": "WebDAV error when URL scheme is missing"},
"webdavErrorHttpsRequired": "WebDAV URL must use https",
"@webdavErrorHttpsRequired": {"description": "WebDAV error when HTTPS is required"},
"webdavErrorInvalidHost": "Invalid URL: hostname is required",
"@webdavErrorInvalidHost": {"description": "WebDAV error when hostname is missing"},
"webdavErrorAuthFailed": "Authentication failed. Check username and password.",
"@webdavErrorAuthFailed": {"description": "WebDAV error when authentication fails"},
"webdavErrorForbidden": "Access denied. Check permissions on the server.",
"@webdavErrorForbidden": {"description": "WebDAV error when access is forbidden"},
"webdavErrorNotFound": "Server path not found. Check the URL.",
"@webdavErrorNotFound": {"description": "WebDAV error when path is not found"},
"webdavErrorConnectionFailed": "Cannot connect to server. Check URL and network.",
"@webdavErrorConnectionFailed": {"description": "WebDAV error when connection fails"},
"webdavErrorTlsError": "SSL/TLS error. Server certificate may be invalid.",
"@webdavErrorTlsError": {"description": "WebDAV error for TLS issues"},
"webdavErrorTimeout": "Connection timed out. Server may be unreachable.",
"@webdavErrorTimeout": {"description": "WebDAV error when connection times out"},
"webdavErrorInsufficientStorage": "Insufficient storage on server.",
"@webdavErrorInsufficientStorage": {"description": "WebDAV error when server storage is full"},
"webdavErrorUnknown": "Upload failed. Please try again.",
"@webdavErrorUnknown": {"description": "WebDAV fallback error message"},
"queueEmpty": "No downloads in queue",
"@queueEmpty": {"description": "Empty queue state title"},
@@ -1839,5 +1871,75 @@
"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"}
"@libraryFolderNotExist": {"description": "Error when folder doesn't exist"},
"librarySourceDownloaded": "Downloaded",
"@librarySourceDownloaded": {"description": "Badge for tracks downloaded via SpotiFLAC"},
"librarySourceLocal": "Local",
"@librarySourceLocal": {"description": "Badge for tracks from local library scan"},
"libraryFilterAll": "All",
"@libraryFilterAll": {"description": "Filter chip - show all library items"},
"libraryFilterDownloaded": "Downloaded",
"@libraryFilterDownloaded": {"description": "Filter chip - show only downloaded items"},
"libraryFilterLocal": "Local",
"@libraryFilterLocal": {"description": "Filter chip - show only local library items"},
"timeJustNow": "Just now",
"@timeJustNow": {"description": "Relative time - less than a minute ago"},
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
"@timeMinutesAgo": {
"description": "Relative time - minutes ago",
"placeholders": {
"count": {"type": "int"}
}
},
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
"@timeHoursAgo": {
"description": "Relative time - hours ago",
"placeholders": {
"count": {"type": "int"}
}
},
"cloudProviderWebdav": "WebDAV (Synology, Nextcloud, QNAP)",
"@cloudProviderWebdav": {"description": "WebDAV provider option with examples"},
"cloudProviderSftp": "SFTP (SSH File Transfer)",
"@cloudProviderSftp": {"description": "SFTP provider option"},
"cloudProviderNotConfigured": "Not Configured",
"@cloudProviderNotConfigured": {"description": "No cloud provider selected"},
"cloudProviderWebdavTitle": "WebDAV",
"@cloudProviderWebdavTitle": {"description": "WebDAV provider title in picker"},
"cloudProviderWebdavSubtitle": "Synology, Nextcloud, QNAP, ownCloud",
"@cloudProviderWebdavSubtitle": {"description": "WebDAV provider subtitle in picker"},
"cloudProviderSftpTitle": "SFTP",
"@cloudProviderSftpTitle": {"description": "SFTP provider title in picker"},
"cloudProviderSftpSubtitle": "SSH File Transfer Protocol",
"@cloudProviderSftpSubtitle": {"description": "SFTP provider subtitle in picker"},
"cloudTestErrorServerUrlRequired": "Server URL is required",
"@cloudTestErrorServerUrlRequired": {"description": "Error when testing connection without server URL"},
"cloudTestErrorCredentialsRequired": "Username and password are required",
"@cloudTestErrorCredentialsRequired": {"description": "Error when testing connection without credentials"},
"cloudTestSuccessWebdav": "Connected to WebDAV server",
"@cloudTestSuccessWebdav": {"description": "Success message for WebDAV connection test"},
"cloudTestSuccessSftp": "Connected to SFTP server",
"@cloudTestSuccessSftp": {"description": "Success message for SFTP connection test"},
"cloudTestErrorNoProvider": "No provider selected",
"@cloudTestErrorNoProvider": {"description": "Error when testing connection without provider"},
"connectionTestSuccess": "Success: {message}",
"@connectionTestSuccess": {
"description": "Connection test success message",
"placeholders": {
"message": {"type": "String"}
}
},
"uploadStatusPending": "Pending",
"@uploadStatusPending": {"description": "Upload queue status - waiting"},
"uploadStatusUploading": "Uploading",
"@uploadStatusUploading": {"description": "Upload queue status - in progress"},
"uploadStatusDone": "Done",
"@uploadStatusDone": {"description": "Upload queue status - completed"},
"uploadStatusFailed": "Failed",
"@uploadStatusFailed": {"description": "Upload queue status - error"}
}
+5
View File
@@ -44,6 +44,7 @@ class AppSettings {
final String cloudUsername; // Server username
final String cloudPassword; // Server password (stored securely)
final String cloudRemotePath; // Remote folder path (e.g. /Music/SpotiFLAC)
final bool cloudAllowInsecureHttp; // Allow HTTP for WebDAV (insecure)
// Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning
@@ -90,6 +91,7 @@ class AppSettings {
this.cloudUsername = '',
this.cloudPassword = '',
this.cloudRemotePath = '/Music/SpotiFLAC',
this.cloudAllowInsecureHttp = false,
// Local Library defaults
this.localLibraryEnabled = false,
this.localLibraryPath = '',
@@ -137,6 +139,7 @@ class AppSettings {
String? cloudUsername,
String? cloudPassword,
String? cloudRemotePath,
bool? cloudAllowInsecureHttp,
// Local Library
bool? localLibraryEnabled,
String? localLibraryPath,
@@ -182,6 +185,8 @@ class AppSettings {
cloudUsername: cloudUsername ?? this.cloudUsername,
cloudPassword: cloudPassword ?? this.cloudPassword,
cloudRemotePath: cloudRemotePath ?? this.cloudRemotePath,
cloudAllowInsecureHttp:
cloudAllowInsecureHttp ?? this.cloudAllowInsecureHttp,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
+3
View File
@@ -48,6 +48,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
cloudUsername: json['cloudUsername'] as String? ?? '',
cloudPassword: json['cloudPassword'] as String? ?? '',
cloudRemotePath: json['cloudRemotePath'] as String? ?? '/Music/SpotiFLAC',
cloudAllowInsecureHttp:
json['cloudAllowInsecureHttp'] as bool? ?? false,
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryShowDuplicates:
@@ -94,6 +96,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'cloudUsername': instance.cloudUsername,
'cloudPassword': instance.cloudPassword,
'cloudRemotePath': instance.cloudRemotePath,
'cloudAllowInsecureHttp': instance.cloudAllowInsecureHttp,
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
+50 -4
View File
@@ -10,6 +10,7 @@ const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
const _cloudPasswordKey = 'cloud_password';
const _spotifyClientSecretKey = 'spotify_client_secret';
class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@@ -31,6 +32,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
await _loadCloudPassword(prefs);
await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials();
@@ -54,7 +56,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
Future<void> _saveSettings() async {
final prefs = await _prefs;
final settingsToSave = state.copyWith(cloudPassword: '');
final settingsToSave = state.copyWith(
cloudPassword: '',
spotifyClientSecret: '',
);
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson()));
}
@@ -88,6 +93,36 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
}
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey);
final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) {
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret);
}
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
? storedSecret
: (prefsSecret.isNotEmpty ? prefsSecret : '');
if (effectiveSecret != state.spotifyClientSecret) {
state = state.copyWith(spotifyClientSecret: effectiveSecret);
}
if (prefsSecret.isNotEmpty) {
await _saveSettings();
}
}
Future<void> _storeSpotifyClientSecret(String secret) async {
if (secret.isEmpty) {
await _secureStorage.delete(key: _spotifyClientSecretKey);
} else {
await _secureStorage.write(key: _spotifyClientSecretKey, value: secret);
}
}
Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
@@ -193,25 +228,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setSpotifyClientSecret(String clientSecret) {
Future<void> setSpotifyClientSecret(String clientSecret) async {
state = state.copyWith(spotifyClientSecret: clientSecret);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
}
void setSpotifyCredentials(String clientId, String clientSecret) {
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
state = state.copyWith(
spotifyClientId: clientId,
spotifyClientSecret: clientSecret,
);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
_applySpotifyCredentials();
}
void clearSpotifyCredentials() {
Future<void> clearSpotifyCredentials() async {
state = state.copyWith(
spotifyClientId: '',
spotifyClientSecret: '',
);
await _storeSpotifyClientSecret('');
_saveSettings();
_applySpotifyCredentials();
}
@@ -319,6 +357,11 @@ void setUseAllFilesAccess(bool enabled) {
_saveSettings();
}
void setCloudAllowInsecureHttp(bool allowed) {
state = state.copyWith(cloudAllowInsecureHttp: allowed);
_saveSettings();
}
Future<void> setCloudSettings({
bool? enabled,
String? provider,
@@ -326,6 +369,7 @@ void setUseAllFilesAccess(bool enabled) {
String? username,
String? password,
String? remotePath,
bool? allowInsecureHttp,
}) async {
final nextPassword = password ?? state.cloudPassword;
state = state.copyWith(
@@ -335,6 +379,8 @@ void setUseAllFilesAccess(bool enabled) {
cloudUsername: username ?? state.cloudUsername,
cloudPassword: nextPassword,
cloudRemotePath: remotePath ?? state.cloudRemotePath,
cloudAllowInsecureHttp:
allowInsecureHttp ?? state.cloudAllowInsecureHttp,
);
if (password != null) {
await _storeCloudPassword(nextPassword);
+1
View File
@@ -170,6 +170,7 @@ class UploadQueueNotifier extends Notifier<UploadQueueState> {
serverUrl: settings.cloudServerUrl,
username: settings.cloudUsername,
password: settings.cloudPassword,
allowInsecureHttp: settings.cloudAllowInsecureHttp,
onProgress: (sent, total) {
if (total > 0) {
final progress = sent / total;
+3 -3
View File
@@ -202,14 +202,14 @@ class _MainShellState extends ConsumerState<MainShell> {
icon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history_outlined),
child: const Icon(Icons.library_music_outlined),
),
selectedIcon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.history),
child: const Icon(Icons.library_music),
),
label: l10n.navHistory,
label: l10n.navLibrary,
),
if (showStore)
NavigationDestination(
+288 -2
View File
@@ -12,9 +12,82 @@ import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
/// Represents the source of a library item
enum LibraryItemSource { downloaded, local }
/// Unified library item that can come from download history or local library
class UnifiedLibraryItem {
final String id;
final String trackName;
final String artistName;
final String albumName;
final String? coverUrl;
final String filePath;
final String? quality;
final DateTime addedAt;
final LibraryItemSource source;
// Original items for navigation
final DownloadHistoryItem? historyItem;
final LocalLibraryItem? localItem;
UnifiedLibraryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.coverUrl,
required this.filePath,
this.quality,
required this.addedAt,
required this.source,
this.historyItem,
this.localItem,
});
factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) {
return UnifiedLibraryItem(
id: 'dl_${item.id}',
trackName: item.trackName,
artistName: item.artistName,
albumName: item.albumName,
coverUrl: item.coverUrl,
filePath: item.filePath,
quality: item.quality,
addedAt: item.downloadedAt,
source: LibraryItemSource.downloaded,
historyItem: item,
);
}
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
String? quality;
if (item.bitDepth != null && item.sampleRate != null) {
quality = '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
}
return UnifiedLibraryItem(
id: 'local_${item.id}',
trackName: item.trackName,
artistName: item.artistName,
albumName: item.albumName,
coverUrl: null, // Local library doesn't have cover URLs
filePath: item.filePath,
quality: quality,
addedAt: item.scannedAt,
source: LibraryItemSource.local,
localItem: item,
);
}
String get searchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}';
String get albumKey => '$albumName|$artistName';
}
class _GroupedAlbum {
final String albumName;
final String artistName;
@@ -664,6 +737,12 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
// Watch local library items
final localLibraryEnabled = ref.watch(settingsProvider.select((s) => s.localLibraryEnabled));
final localLibraryItems = localLibraryEnabled
? ref.watch(localLibraryProvider.select((s) => s.items))
: <LocalLibraryItem>[];
_ensureHistoryCaches(allHistoryItems);
final historyViewMode = ref.watch(
settingsProvider.select((s) => s.historyViewMode),
@@ -720,7 +799,7 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
expandedTitleScale: 1.0,
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
title: Text(
context.l10n.historyTitle,
context.l10n.navLibrary,
style: TextStyle(
fontSize: 20 + (14 * expandRatio),
fontWeight: FontWeight.bold,
@@ -945,6 +1024,7 @@ const Spacer(),
queueItems: queueItems,
groupedAlbums: groupedAlbums,
albumCounts: historyStats.albumCounts,
localLibraryItems: localLibraryItems,
);
},
),
@@ -982,7 +1062,8 @@ child: _buildSelectionBottomBar(
required String historyViewMode,
required List<DownloadItem> queueItems,
required List<_GroupedAlbum> groupedAlbums,
required Map<String, int> albumCounts,
required Map<String, int> albumCounts,
required List<LocalLibraryItem> localLibraryItems,
}) {
final historyItems = _resolveHistoryItems(
filterMode: filterMode,
@@ -1151,8 +1232,57 @@ if (filterMode == 'albums' && filteredGroupedAlbums.isNotEmpty)
}, childCount: historyItems.length ),
),
// Local Library Section
if (localLibraryItems.isNotEmpty && filterMode == 'all')
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Text(
context.l10n.libraryFilterLocal,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${localLibraryItems.length}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
if (localLibraryItems.isNotEmpty && filterMode == 'all')
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = localLibraryItems[index];
return KeyedSubtree(
key: ValueKey('local_${item.id}'),
child: _buildLocalLibraryItem(
context,
item,
colorScheme,
),
);
}, childCount: localLibraryItems.length),
),
if (queueItems.isEmpty &&
historyItems.isEmpty &&
localLibraryItems.isEmpty &&
(filterMode != 'albums' || filteredGroupedAlbums.isEmpty) &&
!showFilteringIndicator)
SliverFillRemaining(
@@ -2084,6 +2214,27 @@ child: CachedNetworkImage(
const SizedBox(height: 2),
Row(
children: [
// Source badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
context.l10n.librarySourceDownloaded,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onPrimaryContainer,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Text(
dateStr,
style: Theme.of(context).textTheme.labelSmall
@@ -2158,6 +2309,141 @@ child: CachedNetworkImage(
),
);
}
Widget _buildLocalLibraryItem(
BuildContext context,
LocalLibraryItem item,
ColorScheme colorScheme,
) {
final fileExists = _checkFileExists(item.filePath);
// Format quality info
String? qualityStr;
if (item.bitDepth != null && item.sampleRate != null) {
qualityStr = '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: InkWell(
onTap: () => _openFile(item.filePath),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Placeholder for cover (local library doesn't have cover URLs)
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.folder_outlined,
color: colorScheme.onSecondaryContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
item.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Row(
children: [
// Source badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
context.l10n.librarySourceLocal,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSecondaryContainer,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
if (qualityStr != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: qualityStr.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
qualityStr,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: qualityStr.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
],
),
),
const SizedBox(width: 8),
if (fileExists)
IconButton(
onPressed: () => _openFile(item.filePath),
icon: Icon(
Icons.play_arrow,
color: colorScheme.primary,
),
tooltip: context.l10n.tooltipPlay,
)
else
Icon(
Icons.error_outline,
color: colorScheme.error,
),
],
),
),
),
);
}
}
class _FilterChip extends StatelessWidget {
+98 -19
View File
@@ -204,6 +204,21 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
],
),
),
if (settings.cloudProvider == 'webdav')
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.warning_amber_outlined,
title: context.l10n.cloudSettingsAllowHttpTitle,
subtitle: context.l10n.cloudSettingsAllowHttpSubtitle,
value: settings.cloudAllowInsecureHttp,
onChanged: _handleAllowHttpChanged,
showDivider: false,
),
],
),
),
// Test Connection Button
SliverToBoxAdapter(
@@ -347,11 +362,11 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
String _getProviderName(String provider) {
switch (provider) {
case 'webdav':
return 'WebDAV (Synology, Nextcloud, QNAP)';
return context.l10n.cloudProviderWebdav;
case 'sftp':
return 'SFTP (SSH File Transfer)';
return context.l10n.cloudProviderSftp;
default:
return 'Not Configured';
return context.l10n.cloudProviderNotConfigured;
}
}
@@ -388,8 +403,8 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
),
ListTile(
leading: const Icon(Icons.web),
title: const Text('WebDAV'),
subtitle: const Text('Synology, Nextcloud, QNAP, ownCloud'),
title: Text(context.l10n.cloudProviderWebdavTitle),
subtitle: Text(context.l10n.cloudProviderWebdavSubtitle),
trailing: current == 'webdav' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setCloudProvider('webdav');
@@ -398,8 +413,8 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
),
ListTile(
leading: const Icon(Icons.terminal),
title: const Text('SFTP'),
subtitle: const Text('SSH File Transfer Protocol'),
title: Text(context.l10n.cloudProviderSftpTitle),
subtitle: Text(context.l10n.cloudProviderSftpSubtitle),
trailing: current == 'sftp' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setCloudProvider('sftp');
@@ -424,7 +439,7 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
if (settings.cloudServerUrl.isEmpty) {
setState(() {
_isTestingConnection = false;
_connectionTestResult = 'Error: Server URL is required';
_connectionTestResult = context.l10n.errorGeneric(context.l10n.cloudTestErrorServerUrlRequired);
});
return;
}
@@ -432,7 +447,7 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
if (settings.cloudUsername.isEmpty || settings.cloudPassword.isEmpty) {
setState(() {
_isTestingConnection = false;
_connectionTestResult = 'Error: Username and password are required';
_connectionTestResult = context.l10n.errorGeneric(context.l10n.cloudTestErrorCredentialsRequired);
});
return;
}
@@ -442,13 +457,14 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
serverUrl: settings.cloudServerUrl,
username: settings.cloudUsername,
password: settings.cloudPassword,
allowInsecureHttp: settings.cloudAllowInsecureHttp,
);
setState(() {
_isTestingConnection = false;
_connectionTestResult = result.success
? 'Success: Connected to WebDAV server'
: 'Error: ${result.error}';
? context.l10n.connectionTestSuccess(context.l10n.cloudTestSuccessWebdav)
: context.l10n.errorGeneric(_localizeWebDavError(context, result));
});
} else if (settings.cloudProvider == 'sftp') {
final result = await CloudUploadService.instance.testSFTPConnection(
@@ -460,17 +476,80 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
setState(() {
_isTestingConnection = false;
_connectionTestResult = result.success
? 'Success: Connected to SFTP server'
: 'Error: ${result.error}';
? context.l10n.connectionTestSuccess(context.l10n.cloudTestSuccessSftp)
: context.l10n.errorGeneric(result.error ?? '');
});
} else {
setState(() {
_isTestingConnection = false;
_connectionTestResult = 'Error: No provider selected';
_connectionTestResult = context.l10n.errorGeneric(context.l10n.cloudTestErrorNoProvider);
});
}
}
Future<void> _handleAllowHttpChanged(bool value) async {
if (!value) {
ref.read(settingsProvider.notifier).setCloudAllowInsecureHttp(false);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(context.l10n.cloudSettingsAllowHttpTitle),
content: Text(context.l10n.cloudSettingsAllowHttpMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.cloudSettingsAllowHttpConfirm),
),
],
);
},
);
if (confirmed == true) {
ref.read(settingsProvider.notifier).setCloudAllowInsecureHttp(true);
}
}
String _localizeWebDavError(
BuildContext context,
CloudUploadResult result,
) {
switch (result.errorCode) {
case 'webdav_invalid_scheme':
return context.l10n.webdavErrorInvalidScheme;
case 'webdav_https_required':
return context.l10n.webdavErrorHttpsRequired;
case 'webdav_invalid_host':
return context.l10n.webdavErrorInvalidHost;
case 'webdav_auth_failed':
return context.l10n.webdavErrorAuthFailed;
case 'webdav_forbidden':
return context.l10n.webdavErrorForbidden;
case 'webdav_not_found':
return context.l10n.webdavErrorNotFound;
case 'webdav_connection_failed':
return context.l10n.webdavErrorConnectionFailed;
case 'webdav_tls_error':
return context.l10n.webdavErrorTlsError;
case 'webdav_timeout':
return context.l10n.webdavErrorTimeout;
case 'webdav_insufficient_storage':
return context.l10n.webdavErrorInsufficientStorage;
case 'webdav_unknown':
return result.error ?? context.l10n.webdavErrorUnknown;
default:
return result.error ?? context.l10n.webdavErrorUnknown;
}
}
Future<void> _resetSftpHostKey() async {
final settings = ref.read(settingsProvider);
if (settings.cloudServerUrl.isEmpty) {
@@ -586,28 +665,28 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
context,
Icons.hourglass_empty,
uploadState.pendingCount.toString(),
'Pending',
context.l10n.uploadStatusPending,
colorScheme.tertiary,
),
_buildStatItem(
context,
Icons.cloud_upload,
uploadState.uploadingCount.toString(),
'Uploading',
context.l10n.uploadStatusUploading,
colorScheme.primary,
),
_buildStatItem(
context,
Icons.check_circle,
uploadState.completedCount.toString(),
'Done',
context.l10n.uploadStatusDone,
Colors.green,
),
_buildStatItem(
context,
Icons.error,
uploadState.failedCount.toString(),
'Failed',
context.l10n.uploadStatusFailed,
colorScheme.error,
),
],
@@ -729,7 +808,7 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
onPressed: () {
ref.read(uploadQueueProvider.notifier).retryFailed(item.id);
},
tooltip: 'Retry',
tooltip: context.l10n.dialogRetry,
);
break;
}
@@ -410,9 +410,9 @@ class _LibraryStatusCard extends StatelessWidget {
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.inMinutes < 1) return context.l10n.timeJustNow;
if (diff.inHours < 1) return context.l10n.timeMinutesAgo(diff.inMinutes);
if (diff.inDays < 1) return context.l10n.timeHoursAgo(diff.inHours);
if (diff.inDays < 7) return context.l10n.dateDaysAgo(diff.inDays);
return '${lastScannedAt!.day}/${lastScannedAt!.month}/${lastScannedAt!.year}';
+117 -22
View File
@@ -10,11 +10,13 @@ import 'package:spotiflac_android/utils/logger.dart';
class CloudUploadResult {
final bool success;
final String? error;
final String? errorCode;
final String? remotePath;
const CloudUploadResult({
required this.success,
this.error,
this.errorCode,
this.remotePath,
});
@@ -23,9 +25,11 @@ class CloudUploadResult {
remotePath: remotePath,
);
factory CloudUploadResult.failure(String error) => CloudUploadResult(
factory CloudUploadResult.failure(String error, {String? errorCode}) =>
CloudUploadResult(
success: false,
error: error,
errorCode: errorCode,
);
}
@@ -37,6 +41,13 @@ class SftpServerInfo {
const SftpServerInfo({required this.host, required this.port});
}
class _WebDavError {
final String code;
final String message;
const _WebDavError({required this.code, required this.message});
}
/// Service for uploading files to cloud storage (WebDAV, SFTP)
class CloudUploadService {
static CloudUploadService? _instance;
@@ -51,6 +62,7 @@ class CloudUploadService {
String? _currentServerUrl;
String? _currentUsername;
String? _currentPassword;
bool? _currentAllowInsecureHttp;
static const _sftpHostKeysKey = 'sftp_known_host_keys';
Map<String, Map<String, String>>? _knownHostKeys;
@@ -78,9 +90,35 @@ class CloudUploadService {
// WebDAV Methods
// ============================================================
bool _isHttpsUrl(String url) {
final uri = Uri.tryParse(url);
return uri != null && uri.scheme == 'https';
_WebDavError? _validateWebDavUrl(
String url, {
required bool allowInsecureHttp,
}) {
final uri = Uri.tryParse(url.trim());
if (uri == null || uri.scheme.isEmpty) {
return const _WebDavError(
code: 'webdav_invalid_scheme',
message: 'Invalid URL: scheme is required',
);
}
final scheme = uri.scheme.toLowerCase();
if (scheme != 'https') {
if (scheme == 'http' && allowInsecureHttp) {
// Explicitly allowed by user
} else {
return const _WebDavError(
code: 'webdav_https_required',
message: 'WebDAV URL must use https',
);
}
}
if (uri.host.isEmpty) {
return const _WebDavError(
code: 'webdav_invalid_host',
message: 'Invalid URL: hostname is required',
);
}
return null;
}
/// Initialize WebDAV client with server credentials
@@ -88,16 +126,22 @@ class CloudUploadService {
required String serverUrl,
required String username,
required String password,
bool allowInsecureHttp = false,
}) async {
if (!_isHttpsUrl(serverUrl)) {
throw ArgumentError('WebDAV URL must use https');
final urlError = _validateWebDavUrl(
serverUrl,
allowInsecureHttp: allowInsecureHttp,
);
if (urlError != null) {
throw ArgumentError(urlError.message);
}
// Reuse existing client if credentials haven't changed
if (_webdavClient != null &&
_currentServerUrl == serverUrl &&
_currentUsername == username &&
_currentPassword == password) {
_currentPassword == password &&
_currentAllowInsecureHttp == allowInsecureHttp) {
return;
}
@@ -111,6 +155,7 @@ class CloudUploadService {
_currentServerUrl = serverUrl;
_currentUsername = username;
_currentPassword = password;
_currentAllowInsecureHttp = allowInsecureHttp;
_logInfo('CloudUpload', 'WebDAV client initialized for $serverUrl');
}
@@ -120,9 +165,17 @@ class CloudUploadService {
required String serverUrl,
required String username,
required String password,
bool allowInsecureHttp = false,
}) async {
if (!_isHttpsUrl(serverUrl)) {
return CloudUploadResult.failure('WebDAV URL must use https.');
final urlError = _validateWebDavUrl(
serverUrl,
allowInsecureHttp: allowInsecureHttp,
);
if (urlError != null) {
return CloudUploadResult.failure(
urlError.message,
errorCode: urlError.code,
);
}
try {
final client = webdav.newClient(
@@ -139,7 +192,11 @@ class CloudUploadService {
return CloudUploadResult.success('/');
} catch (e) {
_logError('CloudUpload', 'WebDAV connection test failed', e.toString());
return CloudUploadResult.failure(_parseWebDAVError(e));
final parsed = _parseWebDAVError(e);
return CloudUploadResult.failure(
parsed.message,
errorCode: parsed.code,
);
}
}
@@ -151,9 +208,17 @@ class CloudUploadService {
required String username,
required String password,
void Function(int sent, int total)? onProgress,
bool allowInsecureHttp = false,
}) async {
if (!_isHttpsUrl(serverUrl)) {
return CloudUploadResult.failure('WebDAV URL must use https.');
final urlError = _validateWebDavUrl(
serverUrl,
allowInsecureHttp: allowInsecureHttp,
);
if (urlError != null) {
return CloudUploadResult.failure(
urlError.message,
errorCode: urlError.code,
);
}
try {
// Initialize client if needed
@@ -161,6 +226,7 @@ class CloudUploadService {
serverUrl: serverUrl,
username: username,
password: password,
allowInsecureHttp: allowInsecureHttp,
);
final client = _webdavClient!;
@@ -189,7 +255,11 @@ class CloudUploadService {
return CloudUploadResult.success(remotePath);
} catch (e) {
_logError('CloudUpload', 'WebDAV upload failed', e.toString());
return CloudUploadResult.failure(_parseWebDAVError(e));
final parsed = _parseWebDAVError(e);
return CloudUploadResult.failure(
parsed.message,
errorCode: parsed.code,
);
}
}
@@ -209,32 +279,56 @@ class CloudUploadService {
}
/// Parse WebDAV error to user-friendly message
String _parseWebDAVError(dynamic error) {
_WebDavError _parseWebDAVError(dynamic error) {
final errorStr = error.toString().toLowerCase();
if (errorStr.contains('401') || errorStr.contains('unauthorized')) {
return 'Authentication failed. Check username and password.';
return const _WebDavError(
code: 'webdav_auth_failed',
message: 'Authentication failed. Check username and password.',
);
}
if (errorStr.contains('403') || errorStr.contains('forbidden')) {
return 'Access denied. Check permissions on the server.';
return const _WebDavError(
code: 'webdav_forbidden',
message: 'Access denied. Check permissions on the server.',
);
}
if (errorStr.contains('404') || errorStr.contains('not found')) {
return 'Server path not found. Check the URL.';
return const _WebDavError(
code: 'webdav_not_found',
message: 'Server path not found. Check the URL.',
);
}
if (errorStr.contains('connection refused') || errorStr.contains('socket')) {
return 'Cannot connect to server. Check URL and network.';
return const _WebDavError(
code: 'webdav_connection_failed',
message: 'Cannot connect to server. Check URL and network.',
);
}
if (errorStr.contains('certificate') || errorStr.contains('ssl') || errorStr.contains('tls')) {
return 'SSL/TLS error. Server certificate may be invalid.';
return const _WebDavError(
code: 'webdav_tls_error',
message: 'SSL/TLS error. Server certificate may be invalid.',
);
}
if (errorStr.contains('timeout')) {
return 'Connection timed out. Server may be unreachable.';
return const _WebDavError(
code: 'webdav_timeout',
message: 'Connection timed out. Server may be unreachable.',
);
}
if (errorStr.contains('507') || errorStr.contains('insufficient storage')) {
return 'Insufficient storage on server.';
return const _WebDavError(
code: 'webdav_insufficient_storage',
message: 'Insufficient storage on server.',
);
}
return 'Upload failed: ${error.toString()}';
return _WebDavError(
code: 'webdav_unknown',
message: 'Upload failed: ${error.toString()}',
);
}
// ============================================================
@@ -508,6 +602,7 @@ class CloudUploadService {
_currentServerUrl = null;
_currentUsername = null;
_currentPassword = null;
_currentAllowInsecureHttp = null;
}
Future<bool> clearSftpHostKey({required String serverUrl}) async {